diff --git a/src/Elastic.Documentation.Site/Assets/main.ts b/src/Elastic.Documentation.Site/Assets/main.ts index 76ac42937..542e74abc 100644 --- a/src/Elastic.Documentation.Site/Assets/main.ts +++ b/src/Elastic.Documentation.Site/Assets/main.ts @@ -30,7 +30,8 @@ initializeOtel({ // Dynamically import web components after telemetry is initialized // This ensures telemetry is available when the components execute // Parcel will automatically code-split this into a separate chunk -import('./web-components/SearchOrAskAi/SearchOrAskAi') +import('./web-components/NavigationSearch/NavigationSearchComponent') +import('./web-components/AskAi/AskAi') import('./web-components/VersionDropdown') import('./web-components/AppliesToPopover') diff --git a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/AiProviderSelector.tsx b/src/Elastic.Documentation.Site/Assets/web-components/AskAi/AiProviderSelector.tsx similarity index 100% rename from src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/AiProviderSelector.tsx rename to src/Elastic.Documentation.Site/Assets/web-components/AskAi/AiProviderSelector.tsx diff --git a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/SearchOrAskAiButton.tsx b/src/Elastic.Documentation.Site/Assets/web-components/AskAi/AskAi.tsx similarity index 59% rename from src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/SearchOrAskAiButton.tsx rename to src/Elastic.Documentation.Site/Assets/web-components/AskAi/AskAi.tsx index 4da6f97c4..80b4e984b 100644 --- a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/SearchOrAskAiButton.tsx +++ b/src/Elastic.Documentation.Site/Assets/web-components/AskAi/AskAi.tsx @@ -1,39 +1,36 @@ import '../../eui-icons-cache' -import { NavigationSearch } from './NavigationSearch' -import { - // ModalMode, - useModalActions, - useModalIsOpen, - useModalMode, -} from './modal.store' +import { useAskAiModalActions, useAskAiModalIsOpen } from './askAi.modal.store' import { EuiPortal, EuiOverlayMask, EuiFocusTrap, EuiPanel, EuiLoadingSpinner, + EuiProvider, useEuiTheme, } from '@elastic/eui' import { css } from '@emotion/react' -import { useQuery } from '@tanstack/react-query' -import { useEffect, Suspense, lazy } from 'react' +import r2wc from '@r2wc/react-to-web-component' +import { + QueryClient, + QueryClientProvider, + useQuery, +} from '@tanstack/react-query' +import { useEffect, Suspense, lazy, StrictMode } from 'react' + +const queryClient = new QueryClient() // Lazy load the modal component -const SearchOrAskAiModal = lazy(() => - import('./SearchOrAskAiModal').then((module) => ({ - default: module.SearchOrAskAiModal, +const LazyAskAiModal = lazy(() => + import('./AskAiModal').then((module) => ({ + default: module.AskAiModal, })) ) -export const SearchOrAskAiButton = () => { +const AskAiButton = () => { const { euiTheme } = useEuiTheme() - const isModalOpen = useModalIsOpen() - const modalMode = useModalMode() - const { - // openModal, - closeModal, - // setModalMode - } = useModalActions() + const isModalOpen = useAskAiModalIsOpen() + const { openModal, closeModal } = useAskAiModalActions() const { data: isApiAvailable } = useQuery({ queryKey: ['api-health'], @@ -62,43 +59,26 @@ export const SearchOrAskAiButton = () => { padding: 2rem; ` - // const openAndSetModalMode = (mode: ModalMode) => { - // setModalMode(mode) - // if (!isModalOpen) { - // openModal() - // } - // } - - // const openAskAiModal = () => openAndSetModalMode('askAi') - // const openSearchModal = () => openAndSetModalMode('search') - - // Prevent layout jump when hiding the scrollbar by compensating its width - useEffect(() => { const handleKeydown = (event: KeyboardEvent) => { if (event.key === 'Escape') { - closeModal() + //closeModal() + } + + // Cmd+; to open Ask AI modal + if ( + (event.metaKey || event.ctrlKey) && + event.code === 'Semicolon' + ) { + //event.preventDefault() + //openModal() } - // Cmd+K is now handled by NavigationSearch to focus the input - // if ((event.metaKey || event.ctrlKey) && event.key === 'k') { - // event.preventDefault() - // openSearchModal() - // } - - // if ( - // (event.metaKey || event.ctrlKey) && - // event.code === 'Semicolon' - // ) { - // event.preventDefault() - // openAskAiModal() - // // Input focuses itself via its own Cmd+; listener - // } } window.addEventListener('keydown', handleKeydown) return () => { window.removeEventListener('keydown', handleKeydown) } - }, [isModalOpen, modalMode]) + }, [openModal, closeModal]) useEffect(() => { if (!isModalOpen) return @@ -132,19 +112,7 @@ export const SearchOrAskAiButton = () => { } return ( -
- {/**/} - {/* */} - {/* Ask AI Assistant*/} - {/* */} - {/**/} - - - + <> {isModalOpen && ( @@ -161,13 +129,31 @@ export const SearchOrAskAiButton = () => {
} > - + )} - + ) } + +const AskAi = () => { + return ( + + + + + + + + ) +} + +customElements.define('ask-ai', r2wc(AskAi)) diff --git a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/AskAiEvent.ts b/src/Elastic.Documentation.Site/Assets/web-components/AskAi/AskAiEvent.ts similarity index 100% rename from src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/AskAiEvent.ts rename to src/Elastic.Documentation.Site/Assets/web-components/AskAi/AskAiEvent.ts diff --git a/src/Elastic.Documentation.Site/Assets/web-components/AskAi/AskAiModal.tsx b/src/Elastic.Documentation.Site/Assets/web-components/AskAi/AskAiModal.tsx new file mode 100644 index 000000000..9bf088e75 --- /dev/null +++ b/src/Elastic.Documentation.Site/Assets/web-components/AskAi/AskAiModal.tsx @@ -0,0 +1,19 @@ +import { Chat } from './Chat' +import { useAskAiCooldown, useAskAiCooldownActions } from './useAskAiCooldown' +import { useCooldown } from './useCooldown' +import * as React from 'react' + +export const AskAiModal = React.memo(() => { + // Manage cooldown countdowns at the modal level so they continue running + const askAiCooldown = useAskAiCooldown() + const { notifyCooldownFinished: notifyAskAiCooldownFinished } = + useAskAiCooldownActions() + + useCooldown({ + domain: 'askAi', + cooldown: askAiCooldown, + onCooldownFinished: () => notifyAskAiCooldownFinished(), + }) + + return +}) diff --git a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/AskAiSuggestions.tsx b/src/Elastic.Documentation.Site/Assets/web-components/AskAi/AskAiSuggestions.tsx similarity index 95% rename from src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/AskAiSuggestions.tsx rename to src/Elastic.Documentation.Site/Assets/web-components/AskAi/AskAiSuggestions.tsx index 64b7e0aee..bcb1e891f 100644 --- a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/AskAiSuggestions.tsx +++ b/src/Elastic.Documentation.Site/Assets/web-components/AskAi/AskAiSuggestions.tsx @@ -1,4 +1,3 @@ -import { useModalActions } from '../modal.store' import { useChatActions } from './chat.store' import { useIsAskAiCooldownActive } from './useAskAiCooldown' import { EuiButton, EuiText, useEuiTheme, EuiSpacer } from '@elastic/eui' @@ -39,7 +38,6 @@ const ALL_SUGGESTIONS: AskAiSuggestion[] = [ export const AskAiSuggestions = () => { const { submitQuestion } = useChatActions() - const { setModalMode } = useModalActions() const disabled = useIsAskAiCooldownActive() const { euiTheme } = useEuiTheme() @@ -72,7 +70,6 @@ export const AskAiSuggestions = () => { onClick={() => { if (!disabled) { submitQuestion(suggestion.question) - setModalMode('askAi') } }} disabled={disabled} diff --git a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/Chat.test.tsx b/src/Elastic.Documentation.Site/Assets/web-components/AskAi/Chat.test.tsx similarity index 95% rename from src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/Chat.test.tsx rename to src/Elastic.Documentation.Site/Assets/web-components/AskAi/Chat.test.tsx index b4e721cb1..786d9b7c9 100644 --- a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/Chat.test.tsx +++ b/src/Elastic.Documentation.Site/Assets/web-components/AskAi/Chat.test.tsx @@ -1,5 +1,4 @@ -import { cooldownStore } from '../cooldown.store' -import { modalStore } from '../modal.store' +import { cooldownStore } from '../shared/cooldown.store' import { Chat } from './Chat' import { chatStore } from './chat.store' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' @@ -38,10 +37,6 @@ const resetStores = () => { aiProvider: 'LlmGateway', scrollPosition: 0, }) - modalStore.setState({ - isOpen: false, - mode: 'search', - }) cooldownStore.setState({ cooldowns: { search: { cooldown: null, awaitingNewInput: false }, @@ -398,21 +393,4 @@ describe('Chat Component', () => { } }) }) - - describe('Close modal', () => { - it('should close modal when close button is clicked', async () => { - // Arrange - modalStore.setState({ isOpen: true }) - const user = userEvent.setup() - - // Act - render() - await user.click( - screen.getByRole('button', { name: /close ask ai modal/i }) - ) - - // Assert - expect(modalStore.getState().isOpen).toBe(false) - }) - }) }) diff --git a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/Chat.tsx b/src/Elastic.Documentation.Site/Assets/web-components/AskAi/Chat.tsx similarity index 98% rename from src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/Chat.tsx rename to src/Elastic.Documentation.Site/Assets/web-components/AskAi/Chat.tsx index a6bdc6e94..f48a586bf 100644 --- a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/Chat.tsx +++ b/src/Elastic.Documentation.Site/Assets/web-components/AskAi/Chat.tsx @@ -1,11 +1,11 @@ -import { InfoBanner } from '../InfoBanner' -import { KeyboardShortcutsFooter } from '../KeyboardShortcutsFooter' -import { LegalDisclaimer } from '../LegalDisclaimer' -import AiIcon from '../ai-icon.svg' -import { useModalActions } from '../modal.store' import { AskAiSuggestions } from './AskAiSuggestions' import { ChatInput } from './ChatInput' import { ChatMessageList } from './ChatMessageList' +import { InfoBanner } from './InfoBanner' +import { KeyboardShortcutsFooter } from './KeyboardShortcutsFooter' +import { LegalDisclaimer } from './LegalDisclaimer' +import AiIcon from './ai-icon.svg' +import { useAskAiModalActions } from './askAi.modal.store' import { ChatMessage, useChatActions, @@ -119,7 +119,7 @@ export const Chat = () => { } const ChatHeader = () => { - const { closeModal } = useModalActions() + const { closeModal } = useAskAiModalActions() const { clearChat } = useChatActions() const messages = useChatMessages() const { euiTheme } = useEuiTheme() diff --git a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/ChatInput.tsx b/src/Elastic.Documentation.Site/Assets/web-components/AskAi/ChatInput.tsx similarity index 98% rename from src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/ChatInput.tsx rename to src/Elastic.Documentation.Site/Assets/web-components/AskAi/ChatInput.tsx index cf5a28adb..f95ae92f2 100644 --- a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/ChatInput.tsx +++ b/src/Elastic.Documentation.Site/Assets/web-components/AskAi/ChatInput.tsx @@ -1,4 +1,4 @@ -import { ElasticAiAssistantButtonIcon } from '../ElasticAiAssitant' +import { ElasticAiAssistantButtonIcon } from './ElasticAiAssitant' import { euiShadow, useEuiScrollBar, useEuiTheme } from '@elastic/eui' import { css } from '@emotion/react' import { useCallback, useEffect, useRef } from 'react' diff --git a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/ChatMessage.test.tsx b/src/Elastic.Documentation.Site/Assets/web-components/AskAi/ChatMessage.test.tsx similarity index 98% rename from src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/ChatMessage.test.tsx rename to src/Elastic.Documentation.Site/Assets/web-components/AskAi/ChatMessage.test.tsx index 5a71c15e5..dd8c5b5e6 100644 --- a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/ChatMessage.test.tsx +++ b/src/Elastic.Documentation.Site/Assets/web-components/AskAi/ChatMessage.test.tsx @@ -1,5 +1,5 @@ -import { cooldownStore } from '../cooldown.store' -import { ApiError } from '../errorHandling' +import { cooldownStore } from '../shared/cooldown.store' +import { ApiError } from '../shared/errorHandling' import { ChatMessage } from './ChatMessage' import { ChatMessage as ChatMessageType } from './chat.store' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' diff --git a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/ChatMessage.tsx b/src/Elastic.Documentation.Site/Assets/web-components/AskAi/ChatMessage.tsx similarity index 98% rename from src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/ChatMessage.tsx rename to src/Elastic.Documentation.Site/Assets/web-components/AskAi/ChatMessage.tsx index e11e1ea84..974c560de 100644 --- a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/ChatMessage.tsx +++ b/src/Elastic.Documentation.Site/Assets/web-components/AskAi/ChatMessage.tsx @@ -1,9 +1,9 @@ -import { initCopyButton } from '../../../copybutton' -import { hljs } from '../../../hljs' -import { aiGradients } from '../ElasticAiAssitant' -import { SearchOrAskAiErrorCallout } from '../SearchOrAskAiErrorCallout' -import { ApiError } from '../errorHandling' +import { initCopyButton } from '../../copybutton' +import { hljs } from '../../hljs' +import { ErrorCallout } from '../shared/ErrorCallout' +import { ApiError } from '../shared/errorHandling' import { AskAiEvent, ChunkEvent, EventTypes } from './AskAiEvent' +import { aiGradients } from './ElasticAiAssitant' import { GeneratingStatus } from './GeneratingStatus' import { References } from './RelatedResources' import { ChatMessage as ChatMessageType, useConversationId } from './chat.store' @@ -487,7 +487,7 @@ export const ChatMessage = ({ - void + closeModal: () => void + toggleModal: () => void + } +} + +const askAiModalStore = create((set) => ({ + isOpen: false, + actions: { + openModal: () => set({ isOpen: true }), + closeModal: () => set({ isOpen: false }), + toggleModal: () => set((state) => ({ isOpen: !state.isOpen })), + }, +})) + +export const useAskAiModalIsOpen = () => + askAiModalStore((state) => state.isOpen) +export const useAskAiModalActions = () => + askAiModalStore((state) => state.actions) + +export { askAiModalStore } diff --git a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/chat.store.test.ts b/src/Elastic.Documentation.Site/Assets/web-components/AskAi/chat.store.test.ts similarity index 100% rename from src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/chat.store.test.ts rename to src/Elastic.Documentation.Site/Assets/web-components/AskAi/chat.store.test.ts diff --git a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/chat.store.ts b/src/Elastic.Documentation.Site/Assets/web-components/AskAi/chat.store.ts similarity index 99% rename from src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/chat.store.ts rename to src/Elastic.Documentation.Site/Assets/web-components/AskAi/chat.store.ts index 8ab082e63..61994b863 100644 --- a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/chat.store.ts +++ b/src/Elastic.Documentation.Site/Assets/web-components/AskAi/chat.store.ts @@ -1,11 +1,11 @@ -import { logError, logWarn } from '../../../telemetry/logging' -import { cooldownStore } from '../cooldown.store' +import { logError, logWarn } from '../../telemetry/logging' +import { cooldownStore } from '../shared/cooldown.store' import { ApiError, createApiErrorFromResponse, isApiError, isRateLimitError, -} from '../errorHandling' +} from '../shared/errorHandling' import { AskAiEvent, AskAiEventSchema } from './AskAiEvent' import { MessageThrottler } from './MessageThrottler' import { diff --git a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/useAskAiCooldown.ts b/src/Elastic.Documentation.Site/Assets/web-components/AskAi/useAskAiCooldown.ts similarity index 93% rename from src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/useAskAiCooldown.ts rename to src/Elastic.Documentation.Site/Assets/web-components/AskAi/useAskAiCooldown.ts index 90988f6ff..67e026cff 100644 --- a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/useAskAiCooldown.ts +++ b/src/Elastic.Documentation.Site/Assets/web-components/AskAi/useAskAiCooldown.ts @@ -1,4 +1,4 @@ -import { useCooldownState, useCooldownActions } from '../cooldown.store' +import { useCooldownState, useCooldownActions } from '../shared/cooldown.store' export const useAskAiCooldown = () => { const state = useCooldownState('askAi') diff --git a/src/Elastic.Documentation.Site/Assets/web-components/AskAi/useAskAiRateLimitHandler.ts b/src/Elastic.Documentation.Site/Assets/web-components/AskAi/useAskAiRateLimitHandler.ts new file mode 100644 index 000000000..053f32ee4 --- /dev/null +++ b/src/Elastic.Documentation.Site/Assets/web-components/AskAi/useAskAiRateLimitHandler.ts @@ -0,0 +1,6 @@ +import { ApiError } from '../shared/errorHandling' +import { useRateLimitHandler } from '../shared/useRateLimitHandler' + +export function useAskAiRateLimitHandler(error: ApiError | Error | null) { + useRateLimitHandler('askAi', error) +} diff --git a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/useCooldown.ts b/src/Elastic.Documentation.Site/Assets/web-components/AskAi/useCooldown.ts similarity index 93% rename from src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/useCooldown.ts rename to src/Elastic.Documentation.Site/Assets/web-components/AskAi/useCooldown.ts index 11a2cbd03..506aa2114 100644 --- a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/useCooldown.ts +++ b/src/Elastic.Documentation.Site/Assets/web-components/AskAi/useCooldown.ts @@ -1,9 +1,8 @@ -import { useCooldownActions } from './cooldown.store' -import { ModalMode } from './modalmodes' +import { useCooldownActions, CooldownDomain } from '../shared/cooldown.store' import { useEffect, useRef } from 'react' interface UseCooldownParams { - domain: ModalMode + domain: CooldownDomain cooldown: number | null onCooldownFinished: () => void } diff --git a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/useMessageFeedback.ts b/src/Elastic.Documentation.Site/Assets/web-components/AskAi/useMessageFeedback.ts similarity index 96% rename from src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/useMessageFeedback.ts rename to src/Elastic.Documentation.Site/Assets/web-components/AskAi/useMessageFeedback.ts index 076b2fb8b..2f5e3f1be 100644 --- a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/useMessageFeedback.ts +++ b/src/Elastic.Documentation.Site/Assets/web-components/AskAi/useMessageFeedback.ts @@ -1,5 +1,5 @@ -import { logWarn } from '../../../telemetry/logging' -import { traceSpan } from '../../../telemetry/tracing' +import { logWarn } from '../../telemetry/logging' +import { traceSpan } from '../../telemetry/tracing' import { Reaction, useChatActions, useMessageReaction } from './chat.store' import { useMutation } from '@tanstack/react-query' import { useCallback, useRef } from 'react' diff --git a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/useStatusMinDisplay.ts b/src/Elastic.Documentation.Site/Assets/web-components/AskAi/useStatusMinDisplay.ts similarity index 100% rename from src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/useStatusMinDisplay.ts rename to src/Elastic.Documentation.Site/Assets/web-components/AskAi/useStatusMinDisplay.ts diff --git a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/NavigationSearch/NavigationSearch.tsx b/src/Elastic.Documentation.Site/Assets/web-components/NavigationSearch/NavigationSearch.tsx similarity index 96% rename from src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/NavigationSearch/NavigationSearch.tsx rename to src/Elastic.Documentation.Site/Assets/web-components/NavigationSearch/NavigationSearch.tsx index 3f915f0bb..03617f0cc 100644 --- a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/NavigationSearch/NavigationSearch.tsx +++ b/src/Elastic.Documentation.Site/Assets/web-components/NavigationSearch/NavigationSearch.tsx @@ -1,11 +1,11 @@ -import { availableIcons } from '../../../eui-icons-cache' -import { useSearchTerm, useSearchActions } from '../Search/search.store' -import { useIsSearchCooldownActive } from '../Search/useSearchCooldown' -import { useSearchQuery } from '../Search/useSearchQuery' +import { availableIcons } from '../../eui-icons-cache' import { SearchInput } from './SearchInput' import { SearchResultsList } from './SearchResultsList' +import { useSearchTerm, useSearchActions } from './navigationSearch.store' import { useGlobalKeyboardShortcut } from './useGlobalKeyboardShortcut' +import { useIsNavigationSearchCooldownActive } from './useNavigationSearchCooldown' import { useNavigationSearchKeyboardNavigation } from './useNavigationSearchKeyboardNavigation' +import { useNavigationSearchQuery } from './useNavigationSearchQuery' import { EuiInputPopover, useEuiTheme, @@ -27,8 +27,8 @@ export const NavigationSearch = () => { const popoverContentRef = useRef(null) const searchTerm = useSearchTerm() const { setSearchTerm } = useSearchActions() - const isSearchCooldownActive = useIsSearchCooldownActive() - const { isLoading, isFetching, data } = useSearchQuery() + const isSearchCooldownActive = useIsNavigationSearchCooldownActive() + const { isLoading, isFetching, data } = useNavigationSearchQuery() const results = data?.results ?? [] const hasContent = !!searchTerm.trim() diff --git a/src/Elastic.Documentation.Site/Assets/web-components/NavigationSearch/NavigationSearchComponent.tsx b/src/Elastic.Documentation.Site/Assets/web-components/NavigationSearch/NavigationSearchComponent.tsx new file mode 100644 index 000000000..173a9540a --- /dev/null +++ b/src/Elastic.Documentation.Site/Assets/web-components/NavigationSearch/NavigationSearchComponent.tsx @@ -0,0 +1,48 @@ +import '../../eui-icons-cache' +import { NavigationSearch } from './NavigationSearch' +import { EuiProvider } from '@elastic/eui' +import r2wc from '@r2wc/react-to-web-component' +import { + QueryClient, + QueryClientProvider, + useQuery, +} from '@tanstack/react-query' +import { StrictMode } from 'react' + +const queryClient = new QueryClient() + +const NavigationSearchInner = () => { + const { data: isApiAvailable } = useQuery({ + queryKey: ['api-health'], + queryFn: async () => { + const response = await fetch('/docs/_api/v1/', { method: 'POST' }) + return response.ok + }, + staleTime: 60 * 60 * 1000, // 60 minutes + retry: false, + }) + + if (!isApiAvailable) { + return null + } + + return +} + +const NavigationSearchWrapper = () => { + return ( + + + + + + + + ) +} + +customElements.define('navigation-search', r2wc(NavigationSearchWrapper)) diff --git a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/NavigationSearch/SanitizedHtmlContent.tsx b/src/Elastic.Documentation.Site/Assets/web-components/NavigationSearch/SanitizedHtmlContent.tsx similarity index 100% rename from src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/NavigationSearch/SanitizedHtmlContent.tsx rename to src/Elastic.Documentation.Site/Assets/web-components/NavigationSearch/SanitizedHtmlContent.tsx diff --git a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/NavigationSearch/SearchDropdownHeader.tsx b/src/Elastic.Documentation.Site/Assets/web-components/NavigationSearch/SearchDropdownHeader.tsx similarity index 100% rename from src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/NavigationSearch/SearchDropdownHeader.tsx rename to src/Elastic.Documentation.Site/Assets/web-components/NavigationSearch/SearchDropdownHeader.tsx diff --git a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/NavigationSearch/SearchInput.tsx b/src/Elastic.Documentation.Site/Assets/web-components/NavigationSearch/SearchInput.tsx similarity index 100% rename from src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/NavigationSearch/SearchInput.tsx rename to src/Elastic.Documentation.Site/Assets/web-components/NavigationSearch/SearchInput.tsx diff --git a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/NavigationSearch/SearchResultsList.tsx b/src/Elastic.Documentation.Site/Assets/web-components/NavigationSearch/SearchResultsList.tsx similarity index 97% rename from src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/NavigationSearch/SearchResultsList.tsx rename to src/Elastic.Documentation.Site/Assets/web-components/NavigationSearch/SearchResultsList.tsx index 91ad1d80e..253aec87b 100644 --- a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/NavigationSearch/SearchResultsList.tsx +++ b/src/Elastic.Documentation.Site/Assets/web-components/NavigationSearch/SearchResultsList.tsx @@ -1,6 +1,9 @@ -import { useSelectedIndex, useSearchActions } from '../Search/search.store' -import { useSearchQuery, SearchResultItem } from '../Search/useSearchQuery' import { SanitizedHtmlContent } from './SanitizedHtmlContent' +import { useSelectedIndex, useSearchActions } from './navigationSearch.store' +import { + useNavigationSearchQuery, + SearchResultItem, +} from './useNavigationSearchQuery' import { EuiBadge, EuiIcon, @@ -29,7 +32,7 @@ export const SearchResultsList = ({ const { euiTheme } = useEuiTheme() const selectedIndex = useSelectedIndex() const { setSelectedIndex } = useSearchActions() - const { isLoading, data } = useSearchQuery() + const { isLoading, data } = useNavigationSearchQuery() const results = data?.results ?? [] const isInitialLoading = isLoading && !data diff --git a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/NavigationSearch/index.ts b/src/Elastic.Documentation.Site/Assets/web-components/NavigationSearch/index.ts similarity index 100% rename from src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/NavigationSearch/index.ts rename to src/Elastic.Documentation.Site/Assets/web-components/NavigationSearch/index.ts diff --git a/src/Elastic.Documentation.Site/Assets/web-components/NavigationSearch/navigationSearch.store.test.ts b/src/Elastic.Documentation.Site/Assets/web-components/NavigationSearch/navigationSearch.store.test.ts new file mode 100644 index 000000000..f64bffabb --- /dev/null +++ b/src/Elastic.Documentation.Site/Assets/web-components/NavigationSearch/navigationSearch.store.test.ts @@ -0,0 +1,73 @@ +import { navigationSearchStore } from './navigationSearch.store' +import { act } from 'react' + +describe('navigationSearch.store', () => { + beforeEach(() => { + // Reset store state before each test + act(() => { + navigationSearchStore.getState().actions.clearSearchTerm() + }) + }) + + describe('setSearchTerm', () => { + it('should set search term', () => { + // Arrange + const searchTerm = 'elasticsearch' + + // Act + act(() => { + navigationSearchStore + .getState() + .actions.setSearchTerm(searchTerm) + }) + + // Assert + expect(navigationSearchStore.getState().searchTerm).toBe(searchTerm) + }) + + it('should update existing search term', () => { + // Arrange + act(() => { + navigationSearchStore + .getState() + .actions.setSearchTerm('old term') + }) + + // Act + act(() => { + navigationSearchStore + .getState() + .actions.setSearchTerm('new term') + }) + + // Assert + expect(navigationSearchStore.getState().searchTerm).toBe('new term') + }) + }) + + describe('clearSearchTerm', () => { + it('should clear search term', () => { + // Arrange + act(() => { + navigationSearchStore + .getState() + .actions.setSearchTerm('test search') + }) + + // Act + act(() => { + navigationSearchStore.getState().actions.clearSearchTerm() + }) + + // Assert + expect(navigationSearchStore.getState().searchTerm).toBe('') + }) + }) + + describe('initial state', () => { + it('should have empty search term on initialization', () => { + // Assert + expect(navigationSearchStore.getState().searchTerm).toBe('') + }) + }) +}) diff --git a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/search.store.ts b/src/Elastic.Documentation.Site/Assets/web-components/NavigationSearch/navigationSearch.store.ts similarity index 71% rename from src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/search.store.ts rename to src/Elastic.Documentation.Site/Assets/web-components/NavigationSearch/navigationSearch.store.ts index 35722a0f9..5cb22d4f5 100644 --- a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/search.store.ts +++ b/src/Elastic.Documentation.Site/Assets/web-components/NavigationSearch/navigationSearch.store.ts @@ -5,7 +5,7 @@ export type TypeFilter = 'all' | 'doc' | 'api' /** -1 indicates no item is selected (e.g., before user starts typing) */ export const NO_SELECTION = -1 -interface SearchState { +interface NavigationSearchState { searchTerm: string page: number typeFilter: TypeFilter @@ -20,7 +20,7 @@ interface SearchState { } } -export const searchStore = create((set) => ({ +export const navigationSearchStore = create((set) => ({ searchTerm: '', page: 0, typeFilter: 'all', @@ -42,9 +42,12 @@ export const searchStore = create((set) => ({ }, })) -export const useSearchTerm = () => searchStore((state) => state.searchTerm) -export const usePageNumber = () => searchStore((state) => state.page) -export const useTypeFilter = () => searchStore((state) => state.typeFilter) +export const useSearchTerm = () => + navigationSearchStore((state) => state.searchTerm) +export const usePageNumber = () => navigationSearchStore((state) => state.page) +export const useTypeFilter = () => + navigationSearchStore((state) => state.typeFilter) export const useSelectedIndex = () => - searchStore((state) => state.selectedIndex) -export const useSearchActions = () => searchStore((state) => state.actions) + navigationSearchStore((state) => state.selectedIndex) +export const useSearchActions = () => + navigationSearchStore((state) => state.actions) diff --git a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/NavigationSearch/useGlobalKeyboardShortcut.ts b/src/Elastic.Documentation.Site/Assets/web-components/NavigationSearch/useGlobalKeyboardShortcut.ts similarity index 100% rename from src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/NavigationSearch/useGlobalKeyboardShortcut.ts rename to src/Elastic.Documentation.Site/Assets/web-components/NavigationSearch/useGlobalKeyboardShortcut.ts diff --git a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/useSearchCooldown.ts b/src/Elastic.Documentation.Site/Assets/web-components/NavigationSearch/useNavigationSearchCooldown.ts similarity index 55% rename from src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/useSearchCooldown.ts rename to src/Elastic.Documentation.Site/Assets/web-components/NavigationSearch/useNavigationSearchCooldown.ts index d371b5655..31fd802d1 100644 --- a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/useSearchCooldown.ts +++ b/src/Elastic.Documentation.Site/Assets/web-components/NavigationSearch/useNavigationSearchCooldown.ts @@ -1,24 +1,24 @@ -import { useCooldownState, useCooldownActions } from '../cooldown.store' +import { useCooldownState, useCooldownActions } from '../shared/cooldown.store' -export const useSearchCooldown = () => { +export const useNavigationSearchCooldown = () => { const state = useCooldownState('search') return state.cooldown } -export const useIsSearchAwaitingNewInput = () => { +export const useIsNavigationSearchAwaitingNewInput = () => { const state = useCooldownState('search') return state.awaitingNewInput } -export const useIsSearchCooldownActive = () => { - const countdown = useSearchCooldown() +export const useIsNavigationSearchCooldownActive = () => { + const countdown = useNavigationSearchCooldown() return countdown !== null && countdown > 0 } -export const useSearchErrorCalloutState = () => { - const countdown = useSearchCooldown() - const hasActiveCooldown = useIsSearchCooldownActive() - const awaitingNewInput = useIsSearchAwaitingNewInput() +export const useNavigationSearchErrorCalloutState = () => { + const countdown = useNavigationSearchCooldown() + const hasActiveCooldown = useIsNavigationSearchCooldownActive() + const awaitingNewInput = useIsNavigationSearchAwaitingNewInput() return { countdown, @@ -27,7 +27,7 @@ export const useSearchErrorCalloutState = () => { } } -export const useSearchCooldownActions = () => { +export const useNavigationSearchCooldownActions = () => { const actions = useCooldownActions() return { setCooldown: (cooldown: number | null) => diff --git a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/NavigationSearch/useNavigationSearchKeyboardNavigation.ts b/src/Elastic.Documentation.Site/Assets/web-components/NavigationSearch/useNavigationSearchKeyboardNavigation.ts similarity index 97% rename from src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/NavigationSearch/useNavigationSearchKeyboardNavigation.ts rename to src/Elastic.Documentation.Site/Assets/web-components/NavigationSearch/useNavigationSearchKeyboardNavigation.ts index c463923a7..0d7170c61 100644 --- a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/NavigationSearch/useNavigationSearchKeyboardNavigation.ts +++ b/src/Elastic.Documentation.Site/Assets/web-components/NavigationSearch/useNavigationSearchKeyboardNavigation.ts @@ -1,4 +1,4 @@ -import { useSelectedIndex, useSearchActions } from '../Search/search.store' +import { useSelectedIndex, useSearchActions } from './navigationSearch.store' import { useRef, useCallback, MutableRefObject } from 'react' interface Options { diff --git a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/useSearchQuery.ts b/src/Elastic.Documentation.Site/Assets/web-components/NavigationSearch/useNavigationSearchQuery.ts similarity index 82% rename from src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/useSearchQuery.ts rename to src/Elastic.Documentation.Site/Assets/web-components/NavigationSearch/useNavigationSearchQuery.ts index b1beae705..a43073a5a 100644 --- a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/useSearchQuery.ts +++ b/src/Elastic.Documentation.Site/Assets/web-components/NavigationSearch/useNavigationSearchQuery.ts @@ -4,16 +4,23 @@ import { ATTR_SEARCH_RESULTS_TOTAL, ATTR_SEARCH_RESULTS_COUNT, ATTR_SEARCH_PAGE_COUNT, -} from '../../../telemetry/semconv' -import { traceSpan } from '../../../telemetry/tracing' -import { createApiErrorFromResponse, shouldRetry } from '../errorHandling' -import { ApiError } from '../errorHandling' -import { usePageNumber, useSearchTerm, useTypeFilter } from './search.store' +} from '../../telemetry/semconv' +import { traceSpan } from '../../telemetry/tracing' import { - useIsSearchAwaitingNewInput, - useSearchCooldownActions, - useIsSearchCooldownActive, -} from './useSearchCooldown' + createApiErrorFromResponse, + shouldRetry, +} from '../shared/errorHandling' +import { ApiError } from '../shared/errorHandling' +import { + usePageNumber, + useSearchTerm, + useTypeFilter, +} from './navigationSearch.store' +import { + useIsNavigationSearchAwaitingNewInput, + useNavigationSearchCooldownActions, + useIsNavigationSearchCooldownActive, +} from './useNavigationSearchCooldown' import { keepPreviousData, useQuery, @@ -54,15 +61,15 @@ const SearchResponse = z.object({ export type SearchResponse = z.infer -export const useSearchQuery = () => { +export const useNavigationSearchQuery = () => { const searchTerm = useSearchTerm() const pageNumber = usePageNumber() + 1 const typeFilter = useTypeFilter() const trimmedSearchTerm = searchTerm.trim() const debouncedSearchTerm = useDebounce(trimmedSearchTerm, 300) - const isCooldownActive = useIsSearchCooldownActive() - const awaitingNewInput = useIsSearchAwaitingNewInput() - const { acknowledgeCooldownFinished } = useSearchCooldownActions() + const isCooldownActive = useIsNavigationSearchCooldownActive() + const awaitingNewInput = useIsNavigationSearchAwaitingNewInput() + const { acknowledgeCooldownFinished } = useNavigationSearchCooldownActions() const previousSearchTermRef = useRef(debouncedSearchTerm) const queryClient = useQueryClient() @@ -83,7 +90,7 @@ export const useSearchQuery = () => { const query = useQuery({ queryKey: [ - 'search', + 'navigation-search', { searchTerm: debouncedSearchTerm.toLowerCase(), pageNumber, @@ -99,7 +106,7 @@ export const useSearchQuery = () => { }) } - return traceSpan('execute search', async (span) => { + return traceSpan('execute navigation search', async (span) => { // Track frontend search (even if backend response is cached by CloudFront) span.setAttribute(ATTR_SEARCH_QUERY, debouncedSearchTerm) span.setAttribute(ATTR_SEARCH_PAGE, pageNumber) @@ -115,7 +122,7 @@ export const useSearchQuery = () => { } const response = await fetch( - '/docs/_api/v1/search?' + params.toString(), + '/docs/_api/v1/navigation-search?' + params.toString(), { signal } ) if (!response.ok) { @@ -152,7 +159,7 @@ export const useSearchQuery = () => { const cancelQuery = useCallback(() => { queryClient.cancelQueries({ queryKey: [ - 'search', + 'navigation-search', { searchTerm: debouncedSearchTerm.toLowerCase(), pageNumber, diff --git a/src/Elastic.Documentation.Site/Assets/web-components/NavigationSearch/useNavigationSearchRateLimitHandler.ts b/src/Elastic.Documentation.Site/Assets/web-components/NavigationSearch/useNavigationSearchRateLimitHandler.ts new file mode 100644 index 000000000..96a64526c --- /dev/null +++ b/src/Elastic.Documentation.Site/Assets/web-components/NavigationSearch/useNavigationSearchRateLimitHandler.ts @@ -0,0 +1,8 @@ +import { ApiError } from '../shared/errorHandling' +import { useRateLimitHandler } from '../shared/useRateLimitHandler' + +export function useNavigationSearchRateLimitHandler( + error: ApiError | Error | null +) { + useRateLimitHandler('search', error) +} diff --git a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/useAskAiRateLimitHandler.ts b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/useAskAiRateLimitHandler.ts deleted file mode 100644 index dcfd62700..000000000 --- a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/useAskAiRateLimitHandler.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { ApiError } from '../errorHandling' -import { useRateLimitHandler } from '../useRateLimitHandler' - -export function useAskAiRateLimitHandler(error: ApiError | Error | null) { - useRateLimitHandler('askAi', error) -} diff --git a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/AskAiButton.tsx b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/AskAiButton.tsx deleted file mode 100644 index 60917a20a..000000000 --- a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/AskAiButton.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import { useIsAskAiCooldownActive } from '../AskAi/useAskAiCooldown' -import { EuiButton, EuiIcon, useEuiTheme } from '@elastic/eui' -import { css } from '@emotion/react' -import { forwardRef } from 'react' - -interface TellMeMoreButtonProps { - term: string - onAsk: () => void - onArrowUp?: () => void - isInputFocused: boolean -} - -export const TellMeMoreButton = forwardRef< - HTMLButtonElement, - TellMeMoreButtonProps ->(({ term, onAsk, onArrowUp, isInputFocused }, ref) => { - const isAskAiCooldownActive = useIsAskAiCooldownActive() - const { euiTheme } = useEuiTheme() - - const askAiButtonStyles = css` - font-weight: ${euiTheme.font.weight.bold}; - color: ${euiTheme.colors.link}; - ` - - return ( -
- span { - display: flex; - align-items: center; - justify-content: flex-start; - width: 100%; - gap: ${euiTheme.size.s}; - } - margin-inline: 1px; - border: none; - position: relative; - :focus .return-key-icon { - visibility: visible; - } - `} - color="text" - fullWidth - onClick={onAsk} - disabled={isAskAiCooldownActive} - onKeyDown={(e) => { - if (e.key === 'ArrowUp') { - e.preventDefault() - onArrowUp?.() - } - }} - > - - Tell me more about  - {term} - - - -
- ) -}) - -TellMeMoreButton.displayName = 'TellMeMoreButton' diff --git a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/Search.test.tsx b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/Search.test.tsx deleted file mode 100644 index 5abbdccb4..000000000 --- a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/Search.test.tsx +++ /dev/null @@ -1,506 +0,0 @@ -import { chatStore } from '../AskAi/chat.store' -import { cooldownStore } from '../cooldown.store' -import { modalStore } from '../modal.store' -import { Search } from './Search' -import { searchStore, NO_SELECTION } from './search.store' -import { SearchResultItem } from './useSearchQuery' -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { render, screen, waitFor, act } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import * as React from 'react' - -// Mock external HTTP calls -jest.mock('@microsoft/fetch-event-source', () => ({ - fetchEventSource: jest.fn(), - EventStreamContentType: 'text/event-stream', -})) - -// Mock fetch for search API -const mockFetch = jest.fn() -global.fetch = mockFetch - -// Helper to create a fresh QueryClient for each test -const createTestQueryClient = () => - new QueryClient({ - defaultOptions: { - queries: { - retry: false, - gcTime: 0, - }, - }, - }) - -// Wrapper with QueryClient -const TestWrapper = ({ children }: { children: React.ReactNode }) => { - const queryClient = createTestQueryClient() - return ( - - {children} - - ) -} - -// Helper to reset all stores -const resetStores = () => { - searchStore.setState({ - searchTerm: '', - page: 1, - typeFilter: 'all', - selectedIndex: NO_SELECTION, - }) - chatStore.setState({ - chatMessages: [], - conversationId: null, - aiProvider: 'LlmGateway', - scrollPosition: 0, - }) - modalStore.setState({ isOpen: false, mode: 'search' }) - cooldownStore.setState({ - cooldowns: { - search: { cooldown: null, awaitingNewInput: false }, - askAi: { cooldown: null, awaitingNewInput: false }, - }, - }) -} - -// Helper to mock successful search response -const mockSearchResponse = (results: SearchResultItem[] = []) => { - mockFetch.mockResolvedValue({ - ok: true, - json: () => - Promise.resolve({ - results, - totalResults: results.length, - pageCount: 1, - pageNumber: 1, - pageSize: 10, - }), - }) -} - -describe('Search Component', () => { - beforeEach(() => { - jest.clearAllMocks() - resetStores() - mockSearchResponse([]) - }) - - describe('Search input', () => { - it('should render search input field', () => { - // Act - render(, { wrapper: TestWrapper }) - - // Assert - expect( - screen.getByPlaceholderText(/search in docs/i) - ).toBeInTheDocument() - }) - - it('should update store when input changes', async () => { - // Arrange - const user = userEvent.setup() - - // Act - render(, { wrapper: TestWrapper }) - const input = screen.getByPlaceholderText(/search in docs/i) - await user.type(input, 'elasticsearch') - - // Assert - expect(searchStore.getState().searchTerm).toBe('elasticsearch') - }) - - it('should display search term from store', () => { - // Arrange - searchStore.setState({ searchTerm: 'kibana' }) - - // Act - render(, { wrapper: TestWrapper }) - - // Assert - const input = screen.getByPlaceholderText( - /search in docs/i - ) as HTMLInputElement - expect(input.value).toBe('kibana') - }) - - it('should reset selectedIndex when search term changes', async () => { - // Arrange - searchStore.setState({ searchTerm: 'test', selectedIndex: 2 }) - const user = userEvent.setup() - - // Act - render(, { wrapper: TestWrapper }) - const input = screen.getByPlaceholderText(/search in docs/i) - await user.type(input, 'x') - - // Assert - selectedIndex should reset to 0 - expect(searchStore.getState().selectedIndex).toBe(0) - }) - }) - - describe('Ask AI button', () => { - it('should not show Ask AI button when search term is empty', () => { - // Act - render(, { wrapper: TestWrapper }) - - // Assert - expect( - screen.queryByRole('button', { name: /tell me more about/i }) - ).not.toBeInTheDocument() - }) - - it('should show Ask AI button when search term exists', () => { - // Arrange - searchStore.setState({ searchTerm: 'elasticsearch' }) - - // Act - render(, { wrapper: TestWrapper }) - - // Assert - expect( - screen.getByRole('button', { name: /tell me more about/i }) - ).toBeInTheDocument() - }) - - it('should trigger chat when Ask AI button is clicked', async () => { - // Arrange - searchStore.setState({ searchTerm: 'what is kibana' }) - const user = userEvent.setup() - - // Act - render(, { wrapper: TestWrapper }) - await user.click( - screen.getByRole('button', { name: /tell me more about/i }) - ) - - // Assert - chat store should have user message and mode should change - await waitFor(() => { - const messages = chatStore.getState().chatMessages - expect(messages.length).toBeGreaterThanOrEqual(1) - expect(messages[0].content).toBe( - 'Tell me more about what is kibana' - ) - }) - expect(modalStore.getState().mode).toBe('askAi') - }) - - it('should not submit whitespace-only search term', async () => { - // Arrange - searchStore.setState({ searchTerm: ' ' }) - const user = userEvent.setup() - - // Act - render(, { wrapper: TestWrapper }) - // Button should still be visible for whitespace - const button = screen.queryByRole('button', { - name: /tell me more about/i, - }) - if (button) { - await user.click(button) - } - - // Assert - chat should not be triggered - expect(chatStore.getState().chatMessages).toHaveLength(0) - }) - }) - - describe('Search on Enter', () => { - it('should not trigger chat when Enter is pressed with no results', async () => { - // Arrange - searchStore.setState({ searchTerm: 'elasticsearch query' }) - const user = userEvent.setup() - - // Act - render(, { wrapper: TestWrapper }) - const input = screen.getByPlaceholderText(/search in docs/i) - await user.click(input) - await user.keyboard('{Enter}') - - // Assert - chat should not be triggered - expect(chatStore.getState().chatMessages).toHaveLength(0) - expect(modalStore.getState().mode).toBe('search') - }) - - it('should not submit empty search on Enter', async () => { - // Arrange - const user = userEvent.setup() - - // Act - render(, { wrapper: TestWrapper }) - const input = screen.getByPlaceholderText(/search in docs/i) - await user.click(input) - await user.keyboard('{Enter}') - - // Assert - expect(chatStore.getState().chatMessages).toHaveLength(0) - }) - }) - - describe('Search results', () => { - it('should fetch and display search results', async () => { - // Arrange - mockSearchResponse([ - { - type: 'doc', - url: '/test1', - title: 'Test Result 1', - description: 'Description 1', - score: 0.9, - parents: [], - }, - ]) - searchStore.setState({ searchTerm: 'test' }) - - // Act - render(, { wrapper: TestWrapper }) - - // Assert - wait for debounced search and result - await waitFor( - () => { - expect( - screen.getByText('Test Result 1') - ).toBeInTheDocument() - }, - { timeout: 1000 } - ) - }) - }) - - describe('Selection navigation', () => { - beforeEach(() => { - mockSearchResponse([ - { - type: 'doc', - url: '/test1', - title: 'Test Result 1', - description: 'Description 1', - score: 0.9, - parents: [], - }, - { - type: 'doc', - url: '/test2', - title: 'Test Result 2', - description: 'Description 2', - score: 0.8, - parents: [], - }, - { - type: 'doc', - url: '/test3', - title: 'Test Result 3', - description: 'Description 3', - score: 0.7, - parents: [], - }, - ]) - }) - - it('should select first item after typing (selectedIndex = 0)', async () => { - // Arrange - start with no selection - expect(searchStore.getState().selectedIndex).toBe(NO_SELECTION) - const user = userEvent.setup() - - // Act - render(, { wrapper: TestWrapper }) - const input = screen.getByPlaceholderText(/search in docs/i) - await user.type(input, 'test') - - await waitFor(() => { - expect(screen.getByText('Test Result 1')).toBeInTheDocument() - }) - - // Assert - selection appears after typing - expect(searchStore.getState().selectedIndex).toBe(0) - }) - - it('should move selection to second result on ArrowDown from input (focus stays on input)', async () => { - // Arrange - const user = userEvent.setup() - - // Act - render(, { wrapper: TestWrapper }) - - const input = screen.getByPlaceholderText(/search in docs/i) - await user.type(input, 'test') - - await waitFor(() => { - expect(screen.getByText('Test Result 1')).toBeInTheDocument() - }) - - // selectedIndex is now 0 after typing - expect(searchStore.getState().selectedIndex).toBe(0) - - await user.keyboard('{ArrowDown}') - - // Assert - selection moved to second result, focus stays on input (Pattern B) - expect(searchStore.getState().selectedIndex).toBe(1) - expect(input).toHaveFocus() - }) - - it('should move focus between results with ArrowDown/ArrowUp', async () => { - // Arrange - searchStore.setState({ searchTerm: 'test' }) - const user = userEvent.setup() - - // Act - render(, { wrapper: TestWrapper }) - - await waitFor(() => { - expect(screen.getByText('Test Result 1')).toBeInTheDocument() - }) - - // Focus first result - const firstResult = screen.getByText('Test Result 1').closest('a')! - await act(async () => { - firstResult.focus() - }) - - // Navigate down - await user.keyboard('{ArrowDown}') - const secondResult = screen.getByText('Test Result 2').closest('a') - expect(secondResult).toHaveFocus() - expect(searchStore.getState().selectedIndex).toBe(1) - - // Navigate up - await user.keyboard('{ArrowUp}') - expect(firstResult).toHaveFocus() - expect(searchStore.getState().selectedIndex).toBe(0) - }) - - it('should stay at first item when ArrowUp from first item (no wrap)', async () => { - // Arrange - const user = userEvent.setup() - - // Act - render(, { wrapper: TestWrapper }) - const input = screen.getByPlaceholderText(/search in docs/i) - await user.type(input, 'test') - - await waitFor(() => { - expect(screen.getByText('Test Result 1')).toBeInTheDocument() - }) - - // Focus first result then press ArrowUp - const firstResult = screen.getByText('Test Result 1').closest('a')! - await act(async () => { - firstResult.focus() - }) - expect(searchStore.getState().selectedIndex).toBe(0) - - await user.keyboard('{ArrowUp}') - - // Assert - stays at first item (no wrap around) - expect(firstResult).toHaveFocus() - expect(searchStore.getState().selectedIndex).toBe(0) - }) - - it('should stay at last item when ArrowDown from last item (no wrap)', async () => { - // Arrange - const user = userEvent.setup() - - // Act - render(, { wrapper: TestWrapper }) - const input = screen.getByPlaceholderText(/search in docs/i) - await user.type(input, 'test') - - await waitFor(() => { - expect(screen.getByText('Test Result 3')).toBeInTheDocument() - }) - - // Focus the last item directly - const lastResult = screen.getByText('Test Result 3').closest('a')! - await act(async () => { - lastResult.focus() - }) - expect(searchStore.getState().selectedIndex).toBe(2) - - // Try to go down from last item - await user.keyboard('{ArrowDown}') - - // Assert - stays at last item (no wrap around) - expect(lastResult).toHaveFocus() - expect(searchStore.getState().selectedIndex).toBe(2) - }) - - it('should render isSelected prop on the selected item', async () => { - // Arrange - searchStore.setState({ searchTerm: 'test', selectedIndex: 1 }) - - // Act - render(, { wrapper: TestWrapper }) - - await waitFor(() => { - expect(screen.getByText('Test Result 2')).toBeInTheDocument() - }) - - // Assert - the second result should have the data-selected attribute - const secondResultLink = screen - .getByText('Test Result 2') - .closest('a') - expect(secondResultLink).toHaveAttribute('data-selected', 'true') - - // First and third should not be selected - const firstResultLink = screen - .getByText('Test Result 1') - .closest('a') - expect(firstResultLink).not.toHaveAttribute('data-selected') - }) - }) - - describe('Loading states', () => { - it('should show loading spinner when fetching', async () => { - // Arrange - slow response - mockFetch.mockImplementation( - () => - new Promise((resolve) => - setTimeout( - () => - resolve({ - ok: true, - json: () => - Promise.resolve({ - results: [], - totalResults: 0, - pageCount: 1, - pageNumber: 1, - pageSize: 10, - }), - }), - 500 - ) - ) - ) - searchStore.setState({ searchTerm: 'test' }) - - // Act - render(, { wrapper: TestWrapper }) - - // Assert - loading spinner should appear - await waitFor(() => { - const spinner = screen.queryByRole('progressbar') - // Spinner appears during loading - expect(spinner || screen.queryByText('test')).toBeTruthy() - }) - }) - }) - - describe('Close modal', () => { - it('should close modal and clear search when close button is clicked', async () => { - // Arrange - modalStore.setState({ isOpen: true }) - searchStore.setState({ searchTerm: 'test' }) - const user = userEvent.setup() - - // Act - render(, { wrapper: TestWrapper }) - await user.click( - screen.getByRole('button', { name: /close search modal/i }) - ) - - // Assert - expect(modalStore.getState().isOpen).toBe(false) - expect(searchStore.getState().searchTerm).toBe('') - }) - }) -}) diff --git a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/Search.tsx b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/Search.tsx deleted file mode 100644 index 30422e97e..000000000 --- a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/Search.tsx +++ /dev/null @@ -1,158 +0,0 @@ -import { InfoBanner } from '../InfoBanner' -import { KeyboardShortcutsFooter } from '../KeyboardShortcutsFooter' -import { LegalDisclaimer } from '../LegalDisclaimer' -import { SearchOrAskAiErrorCallout } from '../SearchOrAskAiErrorCallout' -import { useModalActions } from '../modal.store' -import { SearchResults } from './SearchResults/SearchResults' -import { TellMeMoreButton } from './TellMeMoreButton' -import { useSearchActions, useSearchTerm } from './search.store' -import { useAskAiFromSearch } from './useAskAiFromSearch' -import { useIsSearchCooldownActive } from './useSearchCooldown' -import { useSearchKeyboardNavigation } from './useSearchKeyboardNavigation' -import { useSearchQuery } from './useSearchQuery' -import { - EuiFieldText, - EuiSpacer, - EuiHorizontalRule, - EuiIcon, - EuiLoadingSpinner, - EuiText, - useEuiTheme, - useEuiFontSize, - EuiButtonIcon, -} from '@elastic/eui' -import { css } from '@emotion/react' -import { useEffect } from 'react' - -export const Search = () => { - const searchTerm = useSearchTerm() - const { setSearchTerm, clearSearchTerm } = useSearchActions() - const { closeModal } = useModalActions() - const isSearchCooldownActive = useIsSearchCooldownActive() - const { askAi } = useAskAiFromSearch() - const { isLoading, isFetching, data } = useSearchQuery() - const mFontSize = useEuiFontSize('m').fontSize - const { euiTheme } = useEuiTheme() - - const resultsCount = data?.results?.length ?? 0 - - const handleSearchInputChange = ( - e: React.ChangeEvent - ) => { - setSearchTerm(e.target.value) - } - - const handleCloseModal = () => { - clearSearchTerm() - closeModal() - } - - const { inputRef, buttonRef, itemRefs, filterRefs, handleInputKeyDown } = - useSearchKeyboardNavigation({ - resultsCount, - isLoading: isLoading || isFetching, - }) - - // Listen for Cmd+K to focus input - useEffect(() => { - const handleGlobalKeyDown = (e: KeyboardEvent) => { - if ((e.metaKey || e.ctrlKey) && e.key === 'k') { - e.preventDefault() - inputRef.current?.focus() - } - } - window.addEventListener('keydown', handleGlobalKeyDown) - return () => window.removeEventListener('keydown', handleGlobalKeyDown) - }, [inputRef]) - - const showLoadingSpinner = isLoading || isFetching - - return ( - <> - {!searchTerm.trim() && ( - - )} -
- {showLoadingSpinner ? ( - - ) : ( - - )} - - -
- - - {!showLoadingSpinner && } - {searchTerm && ( -
- - - Ask AI Assistant - - - - - -
- )} - - - - - ) -} - -const SEARCH_KEYBOARD_SHORTCUTS = [ - { keys: ['returnKey'], label: 'Select' }, - { keys: ['sortUp', 'sortDown'], label: 'Navigate' }, - { keys: ['Esc'], label: 'Close' }, -] - -const SearchFooter = () => ( - -) diff --git a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/SearchResults/SearchFilters.tsx b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/SearchResults/SearchFilters.tsx deleted file mode 100644 index 8032a7716..000000000 --- a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/SearchResults/SearchFilters.tsx +++ /dev/null @@ -1,172 +0,0 @@ -import { TypeFilter, useTypeFilter, useSearchActions } from '../search.store' -import { useEuiTheme, EuiButton, EuiSpacer } from '@elastic/eui' -import { css } from '@emotion/react' -import { useCallback, useState, MutableRefObject } from 'react' - -const FILTERS: TypeFilter[] = ['all', 'doc', 'api'] -const FILTER_LABELS: Record = { - all: 'All', - doc: 'Docs', - api: 'API', -} -const FILTER_ICONS: Record = { - all: 'globe', - doc: 'documentation', - api: 'code', -} - -interface SearchFiltersProps { - isLoading: boolean - filterRefs?: MutableRefObject<(HTMLButtonElement | null)[]> -} - -export const SearchFilters = ({ - isLoading, - filterRefs, -}: SearchFiltersProps) => { - if (isLoading) { - return null - } - - const { euiTheme } = useEuiTheme() - const selectedFilter = useTypeFilter() - const { setTypeFilter } = useSearchActions() - - // Track which filter is focused for roving tabindex within the toolbar - const [focusedIndex, setFocusedIndex] = useState(() => - FILTERS.indexOf(selectedFilter) - ) - - // Only the focused filter is tabbable (roving tabindex within toolbar) - const getTabIndex = (index: number): 0 | -1 => { - return index === focusedIndex ? 0 : -1 - } - - // Arrow keys navigate within the toolbar - const handleFilterKeyDown = useCallback( - (e: React.KeyboardEvent, index: number) => { - if (e.key === 'ArrowLeft' && index > 0) { - e.preventDefault() - const newIndex = index - 1 - setFocusedIndex(newIndex) - filterRefs?.current[newIndex]?.focus() - } else if (e.key === 'ArrowRight' && index < FILTERS.length - 1) { - e.preventDefault() - const newIndex = index + 1 - setFocusedIndex(newIndex) - filterRefs?.current[newIndex]?.focus() - } - // Tab naturally exits the toolbar - }, - [filterRefs] - ) - - const handleFilterClick = useCallback( - (filter: TypeFilter, index: number) => { - setTypeFilter(filter) - setFocusedIndex(index) - }, - [setTypeFilter] - ) - - const handleFilterFocus = useCallback((index: number) => { - setFocusedIndex(index) - }, []) - - const getButtonStyle = (isSelected: boolean) => css` - border-radius: 99999px; - padding-inline: ${euiTheme.size.s}; - min-inline-size: auto; - ${isSelected && - ` - background-color: ${euiTheme.colors.backgroundBaseHighlighted}; - border-color: ${euiTheme.colors.borderStrongPrimary}; - color: ${euiTheme.colors.textPrimary}; - border-width: 1px; - border-style: solid; - `} - ${isSelected && - ` - span svg { - fill: ${euiTheme.colors.textPrimary}; - } - `} - &:hover, - &:hover:not(:disabled)::before { - background-color: ${euiTheme.colors.backgroundBaseHighlighted}; - } - &:focus-visible { - background-color: ${euiTheme.colors.backgroundBasePlain}; - } - ${isSelected && - ` - &:hover, - &:focus-visible { - background-color: ${euiTheme.colors.backgroundBaseHighlighted}; - border-color: ${euiTheme.colors.borderStrongPrimary}; - color: ${euiTheme.colors.textPrimary}; - } - `} - span { - gap: 4px; - &.eui-textTruncate { - padding-inline: 4px; - } - svg { - fill: ${isSelected - ? euiTheme.colors.textPrimary - : euiTheme.colors.borderBaseProminent}; - } - } - ` - - return ( -
-
- {FILTERS.map((filter, index) => { - const isSelected = selectedFilter === filter - return ( - handleFilterClick(filter, index)} - onFocus={() => handleFilterFocus(index)} - onKeyDown={( - e: React.KeyboardEvent - ) => handleFilterKeyDown(e, index)} - buttonRef={(el: HTMLButtonElement | null) => { - if (filterRefs) { - filterRefs.current[index] = el - } - }} - tabIndex={getTabIndex(index)} - css={getButtonStyle(isSelected)} - aria-label={ - filter === 'all' - ? 'Show all results' - : filter === 'doc' - ? 'Filter to documentation results' - : 'Filter to API results' - } - aria-pressed={isSelected} - > - {FILTER_LABELS[filter]} - - ) - })} -
- -
- ) -} diff --git a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/SearchResults/SearchResults.tsx b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/SearchResults/SearchResults.tsx deleted file mode 100644 index 332f8e772..000000000 --- a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/SearchResults/SearchResults.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import { SearchOrAskAiErrorCallout } from '../../SearchOrAskAiErrorCallout' -import { useSearchActions, useSearchTerm } from '../search.store' -import { useSearchQuery } from '../useSearchQuery' -import { SearchFilters } from './SearchFilters' -import { SearchResultsList } from './SearchResultsList' -import { - EuiSpacer, - useEuiTheme, - EuiHorizontalRule, - EuiText, -} from '@elastic/eui' -import { css } from '@emotion/react' -import { useDebounce } from '@uidotdev/usehooks' -import { useEffect, MutableRefObject } from 'react' - -interface SearchResultsProps { - itemRefs?: MutableRefObject<(HTMLAnchorElement | null)[]> - filterRefs?: MutableRefObject<(HTMLButtonElement | null)[]> -} - -export const SearchResults = ({ itemRefs, filterRefs }: SearchResultsProps) => { - const { euiTheme } = useEuiTheme() - const searchTerm = useSearchTerm() - const { setPageNumber } = useSearchActions() - const debouncedSearchTerm = useDebounce(searchTerm, 300) - - // Reset to first page when search term changes - useEffect(() => { - setPageNumber(0) - }, [debouncedSearchTerm, setPageNumber]) - - const { data, error, isLoading } = useSearchQuery() - - const results = data?.results ?? [] - - const isInitialLoading = isLoading && !data - - if (!searchTerm) { - return null - } - - return ( - <> - - {error && } - - {!error && ( - <> - - - - - {!isInitialLoading && results.length === 0 && ( - - No results - - )} - - {data && results.length > 0 && ( - - )} - - {isInitialLoading && ( - - )} - - )} - - ) -} diff --git a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/SearchResults/SearchResultsList.tsx b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/SearchResults/SearchResultsList.tsx deleted file mode 100644 index 22eb490ba..000000000 --- a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/SearchResults/SearchResultsList.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import { useSelectedIndex, useSearchActions } from '../search.store' -import { type SearchResultItem } from '../useSearchQuery' -import { SearchResultListItem } from './SearchResultsListItem' -import { useEuiOverflowScroll, useEuiTheme } from '@elastic/eui' -import { css } from '@emotion/react' -import { useRef, useCallback, useEffect, MutableRefObject } from 'react' - -interface SearchResultsListProps { - results: SearchResultItem[] - pageNumber: number - pageSize: number - isLoading: boolean - searchTerm: string - itemRefs?: MutableRefObject<(HTMLAnchorElement | null)[]> -} - -export const SearchResultsList = ({ - results, - pageNumber, - pageSize, - isLoading, - searchTerm, - itemRefs, -}: SearchResultsListProps) => { - if (isLoading) { - return null - } - const { euiTheme } = useEuiTheme() - const selectedIndex = useSelectedIndex() - const { setSelectedIndex } = useSearchActions() - const scrollContainerRef = useRef(null) - - const scrollbarStyle = css` - max-height: 400px; - padding-block-start: ${euiTheme.size.base}; - padding-block-end: ${euiTheme.size.base}; - padding-inline-end: ${euiTheme.size.xs}; - margin-inline-end: ${euiTheme.size.xs}; - ${useEuiOverflowScroll('y', false)} - ` - - const resetScrollToTop = useCallback(() => { - if (scrollContainerRef.current) { - scrollContainerRef.current.scrollTop = 0 - } - }, []) - - useEffect(() => { - resetScrollToTop() - }, [searchTerm, resetScrollToTop]) - - // Roving tabindex: only one item is tabbable - const getTabIndex = useCallback( - (index: number): 0 | -1 => { - const effectiveIndex = selectedIndex >= 0 ? selectedIndex : 0 - return index === effectiveIndex ? 0 : -1 - }, - [selectedIndex] - ) - - // Handle arrow keys when focus is on a result item - const handleItemKeyDown = useCallback( - (e: React.KeyboardEvent, currentIndex: number) => { - if (e.key === 'ArrowDown') { - e.preventDefault() - if (currentIndex < results.length - 1) { - const nextIndex = currentIndex + 1 - setSelectedIndex(nextIndex) - itemRefs?.current[nextIndex]?.focus() - } - } else if (e.key === 'ArrowUp') { - e.preventDefault() - if (currentIndex > 0) { - const prevIndex = currentIndex - 1 - setSelectedIndex(prevIndex) - itemRefs?.current[prevIndex]?.focus() - } - } - }, - [results.length, setSelectedIndex, itemRefs] - ) - - return ( -
-
    - {results.map((result, index) => ( - { - if (itemRefs) { - itemRefs.current[index] = el - } - }} - /> - ))} -
-
- ) -} diff --git a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/SearchResults/SearchResultsListItem.tsx b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/SearchResults/SearchResultsListItem.tsx deleted file mode 100644 index 86c8c2ca9..000000000 --- a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/SearchResults/SearchResultsListItem.tsx +++ /dev/null @@ -1,341 +0,0 @@ -/** @jsxImportSource @emotion/react */ -import { logInfo } from '../../../../telemetry/logging' -import { - ATTR_SEARCH_QUERY, - ATTR_SEARCH_RESULT_URL, - ATTR_SEARCH_RESULT_TITLE, - ATTR_SEARCH_RESULT_POSITION, - ATTR_SEARCH_RESULT_POSITION_ON_PAGE, - ATTR_SEARCH_RESULT_SCORE, - ATTR_SEARCH_PAGE, - ATTR_EVENT_NAME, - ATTR_EVENT_CATEGORY, -} from '../../../../telemetry/semconv' -import { useSearchTerm } from '../search.store' -import { type SearchResultItem } from '../useSearchQuery' -import { - EuiText, - useEuiTheme, - useEuiFontSize, - EuiIcon, - EuiSpacer, -} from '@elastic/eui' -import { css } from '@emotion/react' -import DOMPurify from 'dompurify' -import { memo, useMemo } from 'react' - -function trackSearchResultClick(params: { - query: string - resultUrl: string - resultTitle: string - absolutePosition: number - positionOnPage: number - pageNumber: number - score: number -}): void { - logInfo('search_result_clicked', { - [ATTR_SEARCH_QUERY]: params.query, - [ATTR_SEARCH_RESULT_URL]: params.resultUrl, - [ATTR_SEARCH_RESULT_TITLE]: params.resultTitle, - [ATTR_SEARCH_RESULT_POSITION]: params.absolutePosition, - [ATTR_SEARCH_RESULT_POSITION_ON_PAGE]: params.positionOnPage, - [ATTR_SEARCH_PAGE]: params.pageNumber, - [ATTR_SEARCH_RESULT_SCORE]: params.score, - [ATTR_EVENT_NAME]: 'search_result_clicked', - [ATTR_EVENT_CATEGORY]: 'ui', - }) -} - -interface SearchResultListItemProps { - item: SearchResultItem - index: number - pageNumber: number - pageSize: number - isSelected?: boolean - tabIndex?: 0 | -1 - onSelect?: (index: number) => void - onKeyDown?: ( - e: React.KeyboardEvent, - index: number - ) => void - setRef?: (el: HTMLAnchorElement | null) => void -} - -export function SearchResultListItem({ - item: result, - index, - pageNumber, - pageSize, - isSelected, - tabIndex = -1, - onSelect, - onKeyDown, - setRef, -}: SearchResultListItemProps) { - const { euiTheme } = useEuiTheme() - const titleFontSize = useEuiFontSize('m') - const searchQuery = useSearchTerm() - - // Calculate absolute position across all pages - // pageNumber is 0-based, so multiply by pageSize and add the index - const absolutePosition = pageNumber * pageSize + index - - const handleClick = () => { - trackSearchResultClick({ - query: searchQuery, - resultUrl: result.url, - resultTitle: result.title, - absolutePosition, - positionOnPage: index, - pageNumber, - score: result.score, - }) - } - - return ( -
  • - { - // If another result item has focus, move focus to this item - if (document.activeElement instanceof HTMLElement) { - const isResultItem = document.activeElement.closest( - '[data-search-results]' - ) - if ( - isResultItem && - document.activeElement !== e.currentTarget - ) { - e.currentTarget.focus() - } - } - onSelect?.(index) - }} - onFocus={() => onSelect?.(index)} - onKeyDown={(e) => onKeyDown?.(e, index)} - css={css` - display: grid; - grid-template-columns: auto 1fr auto; - align-items: center; - gap: ${euiTheme.size.m}; - border-radius: ${euiTheme.size.s}; - border-width: 1px; - border-style: solid; - border-color: transparent; - padding-inline-start: ${euiTheme.size.m}; - padding-inline-end: ${euiTheme.size.base}; - padding-block: ${euiTheme.size.m}; - margin-inline-start: ${euiTheme.size.base}; - margin-inline-end: ${euiTheme.size.s}; - outline: none; - outline-color: transparent; - - /* Selected: background + border (hover updates selection via onMouseEnter) */ - &[data-selected] { - background-color: ${euiTheme.colors - .backgroundBaseHighlighted}; - border-color: ${euiTheme.colors.borderBasePlain}; - - .return-key-icon { - visibility: visible; - } - } - - /* Focus ring for selected and focus states */ - &:focus-visible { - outline: 2px solid - ${euiTheme.colors.borderStrongPrimary}; - outline-offset: -2px; - border-color: ${euiTheme.colors.borderStrongPrimary}; - } - `} - href={result.url} - > - -
    - {result.parents.length > 0 && ( - <> - - - )} -
    - -
    - - -
    - -
    -
    -
    - -
    -
  • - ) -} - -function Breadcrumbs({ - type, - parents, -}: { - type: SearchResultItem['type'] - parents: SearchResultItem['parents'] -}) { - const { euiTheme } = useEuiTheme() - const { fontSize: smallFontsize } = useEuiFontSize('xs') - return ( -
      -
    • - - {type === 'api' ? 'API' : 'Docs'} - -
    • - {parents.slice(1).map((parent) => ( -
    • - - {parent.title} - -
    • - ))} -
    - ) -} - -const SanitizedHtmlContent = memo( - ({ htmlContent, ellipsis }: { htmlContent: string; ellipsis: boolean }) => { - const processed = useMemo(() => { - if (!htmlContent) return '' - - const sanitized = DOMPurify.sanitize(htmlContent, { - ALLOWED_TAGS: ['mark'], - ALLOWED_ATTR: [], - KEEP_CONTENT: true, - }) - - if (!ellipsis) { - return sanitized - } - - const temp = document.createElement('div') - temp.innerHTML = sanitized - - const text = temp.textContent || '' - const firstChar = text.trim()[0] - - // Add ellipsis when text starts mid-sentence to indicate continuation - if (firstChar && /[a-z]/.test(firstChar)) { - return '… ' + sanitized - } - - return sanitized - }, [htmlContent]) - - return
    - } -) - -SanitizedHtmlContent.displayName = 'SanitizedHtmlContent' diff --git a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/SearchSuggestions.tsx b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/SearchSuggestions.tsx deleted file mode 100644 index b4e24f8ed..000000000 --- a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/SearchSuggestions.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import { useModalActions } from '../modal.store' -import { - EuiButton, - EuiSpacer, - EuiText, - EuiTextTruncate, - useEuiTheme, -} from '@elastic/eui' -import { css } from '@emotion/react' -import htmx from 'htmx.org' -import * as React from 'react' -import { useEffect } from 'react' - -export interface Suggestion { - title: string - url: string -} - -interface Props { - suggestions: Suggestion[] -} - -export const SearchSuggestions = (props: Props) => { - return ( - <> - Suggested pages - - {props.suggestions.map((suggestion) => ( -