diff --git a/src/Elastic.Documentation.Site/Assets/eui-icons-cache.ts b/src/Elastic.Documentation.Site/Assets/eui-icons-cache.ts index df60b9ae9..4e5a6f474 100644 --- a/src/Elastic.Documentation.Site/Assets/eui-icons-cache.ts +++ b/src/Elastic.Documentation.Site/Assets/eui-icons-cache.ts @@ -6,7 +6,9 @@ import { icon as EuiIconArrowStart } from '@elastic/eui/es/components/icon/asset import { icon as EuiIconArrowDown } from '@elastic/eui/es/components/icon/assets/arrow_down' import { icon as EuiIconArrowLeft } from '@elastic/eui/es/components/icon/assets/arrow_left' import { icon as EuiIconArrowRight } from '@elastic/eui/es/components/icon/assets/arrow_right' +import { icon as EuiIconArrowUp } from '@elastic/eui/es/components/icon/assets/arrow_up' import { icon as EuiIconCheck } from '@elastic/eui/es/components/icon/assets/check' +import { icon as EuiIconCode } from '@elastic/eui/es/components/icon/assets/code' import { icon as EuiIconComment } from '@elastic/eui/es/components/icon/assets/comment' import { icon as EuiIconCopy } from '@elastic/eui/es/components/icon/assets/copy' import { icon as EuiIconCopyClipboard } from '@elastic/eui/es/components/icon/assets/copy_clipboard' @@ -24,6 +26,7 @@ import { icon as EuiIconPopout } from '@elastic/eui/es/components/icon/assets/po import { icon as EuiIconRefresh } from '@elastic/eui/es/components/icon/assets/refresh' import { icon as EuiIconReturnKey } from '@elastic/eui/es/components/icon/assets/return_key' import { icon as EuiIconSearch } from '@elastic/eui/es/components/icon/assets/search' +import { icon as EuiIconSortDown } from '@elastic/eui/es/components/icon/assets/sort_down' import { icon as EuiIconSortUp } from '@elastic/eui/es/components/icon/assets/sort_up' import { icon as EuiIconSparkles } from '@elastic/eui/es/components/icon/assets/sparkles' import { icon as EuiIconThumbDown } from '@elastic/eui/es/components/icon/assets/thumbDown' @@ -33,11 +36,13 @@ import { icon as EuiIconUser } from '@elastic/eui/es/components/icon/assets/user import { icon as EuiIconWrench } from '@elastic/eui/es/components/icon/assets/wrench' import { appendIconComponentCache } from '@elastic/eui/es/components/icon/icon' -appendIconComponentCache({ +const iconMapping = { newChat: EuiIconNewChat, + arrowUp: EuiIconArrowUp, arrowDown: EuiIconArrowDown, arrowLeft: EuiIconArrowLeft, arrowRight: EuiIconArrowRight, + code: EuiIconCode, document: EuiIconDocument, dot: EuiIconDot, empty: EuiIconEmpty, @@ -62,7 +67,12 @@ appendIconComponentCache({ copy: EuiIconCopy, play: EuiIconPlay, sortUp: EuiIconSortUp, + sortDown: EuiIconSortDown, arrowStart: EuiIconArrowStart, arrowEnd: EuiIconArrowEnd, comment: EuiIconComment, -}) +} + +appendIconComponentCache(iconMapping) + +export const availableIcons = Object.keys(iconMapping) diff --git a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/Chat.tsx b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/Chat.tsx index 0d742cade..b8f306c0b 100644 --- a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/Chat.tsx +++ b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/Chat.tsx @@ -15,6 +15,7 @@ import { EuiEmptyPrompt, EuiSpacer, EuiTitle, + useEuiTheme, } from '@elastic/eui' import { css } from '@emotion/react' import { useCallback, useEffect, useRef, useState } from 'react' @@ -42,7 +43,6 @@ const messagesStyles = css` margin: 0 auto; ` -// Small helper for scroll behavior const scrollToBottom = (container: HTMLDivElement | null) => { if (!container) return container.scrollTop = container.scrollHeight @@ -74,6 +74,7 @@ const NewConversationHeader = ({ ) export const Chat = () => { + const { euiTheme } = useEuiTheme() const messages = useChatMessages() const { submitQuestion, clearChat, clearNon429Errors, cancelStreaming } = useChatActions() @@ -89,18 +90,15 @@ export const Chat = () => { ${useEuiOverflowScroll('y', true)} ` - // Check if there's an active streaming query const isStreaming = messages.length > 0 && messages[messages.length - 1].type === 'ai' && messages[messages.length - 1].status === 'streaming' - // Handle abort function from StreamingAiMessage - const handleAbortReady = (abort: () => void) => { + const handleAbortReady = useCallback((abort: () => void) => { abortFunctionRef.current = abort - } + }, []) - // Clear abort function when streaming ends useEffect(() => { if (!isStreaming) { abortFunctionRef.current = null @@ -109,16 +107,13 @@ export const Chat = () => { const handleSubmit = useCallback( (question: string) => { - if (!question.trim()) return - - // Prevent submission during countdown - if (isCooldownActive) { + const trimmedQuestion = question.trim() + if (!trimmedQuestion || isCooldownActive) { return } clearNon429Errors() - - submitQuestion(question.trim()) + submitQuestion(trimmedQuestion) if (inputRef.current) { inputRef.current.value = '' @@ -245,6 +240,7 @@ export const Chat = () => {
{ } css={css` position: absolute; - right: 8px; + right: ${euiTheme.size.l}; top: 50%; transform: translateY(-50%); border-radius: 9999px; diff --git a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/highlight-worker.ts b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/highlight-worker.ts deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/InfoBanner.tsx b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/InfoBanner.tsx new file mode 100644 index 000000000..a8c37bc0d --- /dev/null +++ b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/InfoBanner.tsx @@ -0,0 +1,49 @@ +import { + EuiBetaBadge, + EuiHorizontalRule, + EuiLink, + EuiText, + useEuiTheme, +} from '@elastic/eui' +import { css } from '@emotion/react' + +export const InfoBanner = () => { + const { euiTheme } = useEuiTheme() + return ( +
+ +
+ + + + This feature is in private preview.{' '} + + Got feedback? We'd love to hear it! + + +
+
+ ) +} 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 index 7d7208e43..21010931b 100644 --- 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 @@ -642,8 +642,12 @@ describe('Search Component', () => { // Act render() - // Assert - expect(screen.getByRole('progressbar')).toBeInTheDocument() + // Assert - check that the loading spinner exists (it has aria-label="Loading" without trailing space) + const progressbars = screen.getAllByRole('progressbar') + const spinner = progressbars.find( + (el) => el.getAttribute('aria-label') === 'Loading' + ) + expect(spinner).toBeInTheDocument() }) it('should show loading spinner when isFetching is true', () => { 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 index 85a8f22da..9367cd23e 100644 --- a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/Search.tsx +++ b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/Search.tsx @@ -1,5 +1,7 @@ +import { availableIcons } from '../../../eui-icons-cache' import { useChatActions } from '../AskAi/chat.store' import { useIsAskAiCooldownActive } from '../AskAi/useAskAiCooldown' +import { InfoBanner } from '../InfoBanner' import { SearchOrAskAiErrorCallout } from '../SearchOrAskAiErrorCallout' import { useModalActions } from '../modal.store' import { SearchResults } from './SearchResults/SearchResults' @@ -12,6 +14,7 @@ import { EuiFieldText, EuiSpacer, EuiButton, + EuiHorizontalRule, EuiIcon, EuiLoadingSpinner, EuiText, @@ -19,7 +22,7 @@ import { useEuiFontSize, } from '@elastic/eui' import { css } from '@emotion/react' -import { useState } from 'react' +import React, { useState } from 'react' export const Search = () => { const searchTerm = useSearchTerm() @@ -31,22 +34,30 @@ export const Search = () => { const [isInputFocused, setIsInputFocused] = useState(false) const { isLoading, isFetching } = useSearchQuery() const xsFontSize = useEuiFontSize('xs').fontSize + const mFontSize = useEuiFontSize('m').fontSize const { euiTheme } = useEuiTheme() - const handleSearch = (e: React.ChangeEvent) => { + const handleSearchInputChange = ( + e: React.ChangeEvent + ) => { setSearchTerm(e.target.value) } - const handleAskAi = () => { - if (isAskAiCooldownActive || searchTerm.trim() === '') { + const handleAskAiClick = () => { + const trimmedSearchTerm = searchTerm.trim() + if (isAskAiCooldownActive || trimmedSearchTerm === '') { return } - // Always start a new conversation clearChat() - submitQuestion(searchTerm) + submitQuestion(trimmedSearchTerm) setModalMode('askAi') } + const handleCloseModal = () => { + clearSearchTerm() + closeModal() + } + const { inputRef, buttonRef, @@ -54,64 +65,49 @@ export const Search = () => { handleListItemKeyDown, focusLastAvailable, setItemRef, - } = useKeyboardNavigation(handleAskAi) + } = useKeyboardNavigation(handleAskAiClick) return ( <> - {!searchTerm.trim() && ( )} +
+ {isLoading || isFetching ? ( + + ) : ( + + )} setIsInputFocused(true)} onBlur={() => setIsInputFocused(false)} onKeyDown={handleInputKeyDown} disabled={isSearchCooldownActive} /> - {isLoading || isFetching ? ( -
- -
- ) : ( - - )} { size="s" color="text" minWidth={false} - onClick={() => { - clearSearchTerm() - closeModal() - }} + onClick={handleCloseModal} > Esc
+ {searchTerm && ( - <> +
Ask AI assistant @@ -143,11 +141,120 @@ export const Search = () => { ref={buttonRef} term={searchTerm} isInputFocused={isInputFocused} - onAsk={handleAskAi} + onAsk={handleAskAiClick} onArrowUp={focusLastAvailable} /> - +
)} + + + ) } + +const SearchFooter = () => { + const { euiTheme } = useEuiTheme() + return ( + <> + +
+ + + +
+ + ) +} + +interface KeyboardIconProps { + type: string +} + +const KeyboardKey = ({ children }: { children: React.ReactNode }) => { + const { euiTheme } = useEuiTheme() + return ( + + {children} + + ) +} + +const KeyboardIcon = ({ type }: KeyboardIconProps) => { + const { euiTheme } = useEuiTheme() + return ( + + {availableIcons.includes(type) ? ( + + ) : ( + + {type} + + )} + + ) +} + +const KeyboardIconsWithLabel = ({ + types, + label, +}: { + types: string[] + label: string +}) => { + const { euiTheme } = useEuiTheme() + return ( + + + {types.map((type, index) => ( + + ))} + + {label} + + ) +} 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 index 7821029b7..1ec3b7a11 100644 --- 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 @@ -1,14 +1,30 @@ import { SearchOrAskAiErrorCallout } from '../../SearchOrAskAiErrorCallout' -import { usePageNumber, useSearchActions, useSearchTerm } from '../search.store' +import { useSearchActions, useSearchTerm } from '../search.store' +import { useSearchFilters, type FilterType } from '../useSearchFilters' import { useSearchQuery } from '../useSearchQuery' import { SearchResultListItem } from './SearchResultsListItem' -import { EuiPagination, EuiSpacer } from '@elastic/eui' +import { + useEuiOverflowScroll, + EuiSpacer, + useEuiTheme, + EuiHorizontalRule, + EuiButton, + EuiText, + EuiSkeletonRectangle, + EuiSkeletonLoading, + EuiSkeletonText, + EuiSkeletonTitle, +} from '@elastic/eui' import { css } from '@emotion/react' import { useDebounce } from '@uidotdev/usehooks' -import { useEffect } from 'react' +import { useEffect, useRef, useCallback } from 'react' +import type { MouseEvent } from 'react' interface SearchResultsProps { - onKeyDown?: (e: React.KeyboardEvent, index: number) => void + onKeyDown?: ( + e: React.KeyboardEvent, + index: number + ) => void setItemRef?: (element: HTMLAnchorElement | null, index: number) => void } @@ -16,16 +32,41 @@ export const SearchResults = ({ onKeyDown, setItemRef, }: SearchResultsProps) => { + const { euiTheme } = useEuiTheme() const searchTerm = useSearchTerm() - const activePage = usePageNumber() const { setPageNumber: setActivePage } = useSearchActions() const debouncedSearchTerm = useDebounce(searchTerm, 300) + const scrollContainerRef = useRef(null) + + const scrollbarStyle = css` + max-height: 400px; + padding-block: ${euiTheme.size.base}; + margin-right: ${euiTheme.size.s}; + ${useEuiOverflowScroll('y', true)} + ` + + const resetScrollToTop = useCallback(() => { + if (scrollContainerRef.current) { + scrollContainerRef.current.scrollTop = 0 + } + }, []) useEffect(() => { setActivePage(0) - }, [debouncedSearchTerm]) + }, [debouncedSearchTerm, setActivePage]) + + useEffect(() => { + resetScrollToTop() + }, [debouncedSearchTerm, resetScrollToTop]) + + const { data, error, isLoading } = useSearchQuery() - const { data, error } = useSearchQuery() + const { selectedFilters, handleFilterClick, filteredResults, counts } = + useSearchFilters({ + results: data?.results ?? [], + }) + + const isInitialLoading = isLoading && !data if (!searchTerm) { return null @@ -41,43 +82,215 @@ export const SearchResults = ({ {error && } {!error && ( -
- - {data && ( - <> -
    - {data.results.map((result, index) => ( - - ))} -
- -
- - setActivePage(activePage) - } - /> -
- + <> + + + + + + {!isInitialLoading && filteredResults.length === 0 && ( + + No results + )} -
+ +
+ + {[1, 2, 3].map((i) => ( +
  • +
    +
    + +
    +
    + + + + +
    + +
    +
    +
    +
  • + ))} + + } + loadedContent={ + data && ( +
      + {filteredResults.map( + (result, index) => ( + + ) + )} +
    + ) + } + /> +
    + )} ) } + +const Filter = ({ + selectedFilters, + onFilterClick, + counts, + isLoading, +}: { + selectedFilters: Set + onFilterClick: (filter: FilterType, event?: MouseEvent) => void + counts: { + apiResultsCount: number + docsResultsCount: number + totalCount: number + } + isLoading: boolean +}) => { + const { euiTheme } = useEuiTheme() + const { apiResultsCount, docsResultsCount, totalCount } = counts + + const buttonStyle = css` + border-radius: 99999px; + padding-inline: ${euiTheme.size.m}; + min-inline-size: auto; + ` + + const skeletonStyle = css` + border-radius: 99999px; + ` + + return ( +
    + + onFilterClick('all', e)} + css={buttonStyle} + aria-label={`Show all results, ${totalCount} total`} + aria-pressed={selectedFilters.has('all')} + > + {isLoading ? 'ALL' : `ALL (${totalCount})`} + + + + onFilterClick('doc', e)} + css={buttonStyle} + aria-label={`Filter to documentation results, ${docsResultsCount} available`} + aria-pressed={selectedFilters.has('doc')} + > + {isLoading ? 'DOCS' : `DOCS (${docsResultsCount})`} + + + + onFilterClick('api', e)} + css={buttonStyle} + aria-label={`Filter to API results, ${apiResultsCount} available`} + aria-pressed={selectedFilters.has('api')} + > + {isLoading ? 'API' : `API (${apiResultsCount})`} + + +
    + ) +} 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 index 0cab2d657..1e9df8a8b 100644 --- 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 @@ -51,7 +51,10 @@ interface SearchResultListItemProps { index: number pageNumber: number pageSize: number - onKeyDown?: (e: React.KeyboardEvent, index: number) => void + onKeyDown?: ( + e: React.KeyboardEvent, + index: number + ) => void setRef?: (element: HTMLAnchorElement | null, index: number) => void } @@ -84,65 +87,67 @@ export function SearchResultListItem({ } return ( -
  • +
  • setRef?.(el, index)} onClick={handleClick} onKeyDown={(e) => { if (e.key === 'Enter') { handleClick() - // Navigate to the result URL window.location.href = result.url } else { - // Type mismatch: event is from anchor but handler expects HTMLLIElement - onKeyDown?.( - e as unknown as React.KeyboardEvent, - index - ) + onKeyDown?.(e, index) } }} css={css` - display: flex; + display: grid; + grid-template-columns: auto 1fr auto; align-items: center; gap: ${euiTheme.size.base}; - border-radius: ${euiTheme.border.radius.small}; - width: 100%; + border-radius: ${euiTheme.border.radius.medium}; padding-inline: ${euiTheme.size.base}; padding-block: ${euiTheme.size.m}; - :hover { - background-color: ${euiTheme.colors - .backgroundBaseSubdued}; - } + margin-inline: ${euiTheme.size.base}; + border: 1px solid transparent; + :hover, :focus { background-color: ${euiTheme.colors .backgroundBaseSubdued}; - } - :focus .return-key-icon { - visibility: visible; + border-color: ${euiTheme.colors.borderBasePlain}; + .return-key-icon { + visibility: visible; + } } `} tabIndex={0} href={result.url} > - {/**/} -
    + +
    - {result.title} +
    @@ -157,17 +162,12 @@ export function SearchResultListItem({ overflow: hidden; //width: 90%; - - mark { - background-color: transparent; - font-weight: ${euiTheme.font.weight.bold}; - color: ${euiTheme.colors.link}; - } `} > {result.highlightedBody ? ( ) : ( {result.description} @@ -177,7 +177,10 @@ export function SearchResultListItem({ {result.parents.length > 0 && ( <> - + )}
    @@ -195,7 +198,13 @@ export function SearchResultListItem({ ) } -function Breadcrumbs({ parents }: { parents: SearchResultItem['parents'] }) { +function Breadcrumbs({ + type, + parents, +}: { + type: SearchResultItem['type'] + parents: SearchResultItem['parents'] +}) { const { euiTheme } = useEuiTheme() const { fontSize: smallFontsize } = useEuiFontSize('xs') return ( @@ -208,6 +217,23 @@ function Breadcrumbs({ parents }: { parents: SearchResultItem['parents'] }) { list-style: none; `} > +
  • + + {type === 'api' ? 'API' : 'Docs'} + +
  • {parents.slice(1).map((parent) => (
  • { + ({ htmlContent, ellipsis }: { htmlContent: string; ellipsis: boolean }) => { const processed = useMemo(() => { if (!htmlContent) return '' @@ -242,8 +268,13 @@ const SanitizedHtmlContent = memo( KEEP_CONTENT: true, }) + if (!ellipsis) { + return sanitized + } + const temp = document.createElement('div') temp.innerHTML = sanitized + const text = temp.textContent || '' const firstChar = text.trim()[0] diff --git a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/TellMeMoreButton.tsx b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/TellMeMoreButton.tsx index ef6d82faa..34ec57456 100644 --- a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/TellMeMoreButton.tsx +++ b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/TellMeMoreButton.tsx @@ -12,7 +12,7 @@ const gradientContainerStyles = css` background-position: 100% 0%; } } - height: 42px; + padding: var(--outline-size); background: linear-gradient( 90deg, #f04e98 0%, @@ -23,9 +23,6 @@ const gradientContainerStyles = css` ); background-size: 200% 100%; background-position: 0% 0%; - display: flex; - align-items: center; - justify-content: center; border-radius: 4px; animation: gradientMove 3s ease infinite; ` @@ -53,6 +50,7 @@ export const TellMeMoreButton = forwardRef<
    span { display: flex; @@ -61,7 +59,6 @@ export const TellMeMoreButton = forwardRef< width: 100%; gap: ${euiTheme.size.s}; } - margin-inline: 1px; border: none; position: relative; :focus .return-key-icon { diff --git a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/useKeyboardNavigation.ts b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/useKeyboardNavigation.ts index 83418a840..70b601027 100644 --- a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/useKeyboardNavigation.ts +++ b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/useKeyboardNavigation.ts @@ -6,7 +6,7 @@ interface KeyboardNavigationReturn { listItemRefs: MutableRefObject<(HTMLAnchorElement | null)[]> handleInputKeyDown: (e: React.KeyboardEvent) => void handleListItemKeyDown: ( - e: React.KeyboardEvent, + e: React.KeyboardEvent, currentIndex: number ) => void focusLastAvailable: () => void @@ -54,7 +54,7 @@ export const useKeyboardNavigation = ( } const handleListItemKeyDown = ( - e: React.KeyboardEvent, + e: React.KeyboardEvent, currentIndex: number ) => { if (e.key === 'ArrowDown') { diff --git a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/useSearchFilters.ts b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/useSearchFilters.ts new file mode 100644 index 000000000..0e643384e --- /dev/null +++ b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/useSearchFilters.ts @@ -0,0 +1,67 @@ +import { SearchResponse } from './useSearchQuery' +import { useState, useMemo } from 'react' +import type { MouseEvent } from 'react' + +export type FilterType = 'all' | 'doc' | 'api' + +interface UseSearchFiltersOptions { + results: SearchResponse['results'] +} + +export const useSearchFilters = ({ results }: UseSearchFiltersOptions) => { + const [selectedFilters, setSelectedFilters] = useState>( + new Set(['all']) + ) + + const isMultiSelectModifierPressed = (event?: MouseEvent): boolean => { + return !!(event && (event.metaKey || event.altKey || event.ctrlKey)) + } + + const toggleFilter = ( + currentFilters: Set, + filter: FilterType + ): Set => { + const newFilters = new Set(currentFilters) + newFilters.delete('all') + if (newFilters.has(filter)) { + newFilters.delete(filter) + } else { + newFilters.add(filter) + } + return newFilters.size === 0 ? new Set(['all']) : newFilters + } + + const handleFilterClick = (filter: FilterType, event?: MouseEvent) => { + if (filter === 'all') { + setSelectedFilters(new Set(['all'])) + return + } + + if (isMultiSelectModifierPressed(event)) { + setSelectedFilters((prev) => toggleFilter(prev, filter)) + } else { + setSelectedFilters(new Set([filter])) + } + } + + const filteredResults = useMemo(() => { + if (selectedFilters.has('all')) { + return results + } + return results.filter((result) => selectedFilters.has(result.type)) + }, [results, selectedFilters]) + + const counts = useMemo(() => { + const apiResultsCount = results.filter((r) => r.type === 'api').length + const docsResultsCount = results.filter((r) => r.type === 'doc').length + const totalCount = docsResultsCount + apiResultsCount + return { apiResultsCount, docsResultsCount, totalCount } + }, [results]) + + return { + selectedFilters, + handleFilterClick, + filteredResults, + counts, + } +} diff --git a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/useSearchQuery.ts b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/useSearchQuery.ts index 44ef05335..6bca5d3ad 100644 --- a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/useSearchQuery.ts +++ b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/useSearchQuery.ts @@ -29,12 +29,13 @@ const SearchResultItemParent = z.object({ }) const SearchResultItem = z.object({ - type: z.string().default('doc'), + type: z.enum(['doc', 'api']), url: z.string(), title: z.string(), description: z.string(), score: z.number(), parents: z.array(SearchResultItemParent), + highlightedTitle: z.string().nullish(), highlightedBody: z.string().nullish(), }) diff --git a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/SearchOrAskAiButton.tsx b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/SearchOrAskAiButton.tsx index 2ee2adb5b..2a5354893 100644 --- a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/SearchOrAskAiButton.tsx +++ b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/SearchOrAskAiButton.tsx @@ -44,7 +44,7 @@ export const SearchOrAskAiButton = () => { left: 50%; transform: translateX(-50%); top: 48px; - width: 80ch; + width: 640px; max-width: 100%; ` @@ -109,7 +109,11 @@ export const SearchOrAskAiButton = () => { - + diff --git a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/SearchOrAskAiModal.tsx b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/SearchOrAskAiModal.tsx index d6eb3b0e6..94aade940 100644 --- a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/SearchOrAskAiModal.tsx +++ b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/SearchOrAskAiModal.tsx @@ -11,19 +11,17 @@ import { import { useModalActions, useModalMode } from './modal.store' import { useCooldown } from './useCooldown' import { - EuiBetaBadge, - EuiText, - EuiLink, EuiTabbedContent, EuiIcon, - EuiHorizontalRule, type EuiTabbedContentTab, + useEuiTheme, } from '@elastic/eui' import { css } from '@emotion/react' import * as React from 'react' import { useMemo } from 'react' export const SearchOrAskAiModal = React.memo(() => { + const { euiTheme } = useEuiTheme() const modalMode = useModalMode() const { setModalMode } = useModalActions() @@ -70,47 +68,16 @@ export const SearchOrAskAiModal = React.memo(() => { return ( <> setModalMode(tab.id as 'search' | 'askAi')} /> - + {/**/} ) }) - -const ModalFooter = () => { - return ( - <> - -
    - - - - This feature is in private preview (alpha).{' '} - - Got feedback? We'd love to hear it! - - -
    - - ) -} diff --git a/src/api/Elastic.Documentation.Api.Core/Search/SearchUsecase.cs b/src/api/Elastic.Documentation.Api.Core/Search/SearchUsecase.cs index 20d6ed6f9..c94e541a4 100644 --- a/src/api/Elastic.Documentation.Api.Core/Search/SearchUsecase.cs +++ b/src/api/Elastic.Documentation.Api.Core/Search/SearchUsecase.cs @@ -46,7 +46,7 @@ public record SearchRequest { public required string Query { get; init; } public int PageNumber { get; init; } = 1; - public int PageSize { get; init; } = 5; + public int PageSize { get; init; } = 20; } public record SearchResponse @@ -76,4 +76,6 @@ public record SearchResultItem public string[]? Headings { get; init; } public float Score { get; init; } public string? HighlightedBody { get; init; } + + public string? HighlightedTitle { get; init; } } diff --git a/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/Search/ElasticsearchGateway.cs b/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/Search/ElasticsearchGateway.cs index 0f420ab09..f7d17105b 100644 --- a/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/Search/ElasticsearchGateway.cs +++ b/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/Search/ElasticsearchGateway.cs @@ -96,7 +96,7 @@ public ElasticsearchGateway(ElasticsearchOptions elasticsearchOptions, ILogger private static Query BuildLexicalQuery(string searchQuery) { - var tokens = searchQuery.Split(" "); + var tokens = searchQuery.Split(Array.Empty(), StringSplitOptions.RemoveEmptyEntries); if (tokens is ["datastream" or "datastreams" or "data-stream" or "data-streams"]) { // /docs/api/doc/kibana/operation/operation-delete-fleet-epm-packages-pkgname-pkgversion-datastream-assets @@ -276,12 +276,16 @@ private static (int TotalHits, List Results) ProcessSearchResp var hit = response.Hits.ElementAtOrDefault(index); var highlights = hit?.Highlight; + string? highlightedTitle = null; string? highlightedBody = null; if (highlights != null) { if (highlights.TryGetValue("stripped_body", out var bodyHighlights) && bodyHighlights.Count > 0) highlightedBody = string.Join(". ", bodyHighlights.Select(h => h.TrimEnd('.'))); + + if (highlights.TryGetValue("title", out var titleHighlights) && titleHighlights.Count > 0) + highlightedTitle = string.Join(". ", titleHighlights.Select(h => h.TrimEnd('.'))); } return new SearchResultItem @@ -297,6 +301,7 @@ private static (int TotalHits, List Results) ProcessSearchResp Url = parent.Url }).ToArray(), Score = (float)(hit?.Score ?? 0.0), + HighlightedTitle = highlightedTitle, HighlightedBody = highlightedBody }; }).ToList();