diff --git a/.changeset/brave-things-join.md b/.changeset/brave-things-join.md new file mode 100644 index 00000000..f671ce93 --- /dev/null +++ b/.changeset/brave-things-join.md @@ -0,0 +1,15 @@ +--- +"@docsearch/docusaurus-adapter": patch +"@docsearch/sidepanel": patch +"@docsearch/modal": patch +"@docsearch/react": patch +"@docsearch/core": patch +--- + +feat(v5): UI and DX updates + +- Rename the Ask AI assistantId option to agentId (adapter theme.SearchModal.askAi.assistantId → agentId) +- appId and apiKey moved up into @docsearch/core, so they're configured once and shared +- Removed the indexName prop from the Sidepanel +- Facet defaults can now be read from the index searchParameters +- Restored nested grouping of search results diff --git a/.oxlintrc.json b/.oxlintrc.json index b0e55f20..c1c45128 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -117,7 +117,6 @@ "new-cap": ["error"], "no-array-constructor": ["error"], "no-bitwise": ["error"], - "no-continue": ["error"], "no-lonely-if": ["error"], "no-nested-ternary": ["error"], "no-unneeded-ternary": ["error"], diff --git a/adapters/docusaurus-theme-search-algolia/src/__tests__/validateThemeConfig.test.ts b/adapters/docusaurus-theme-search-algolia/src/__tests__/validateThemeConfig.test.ts index 0fa4835f..35050a77 100644 --- a/adapters/docusaurus-theme-search-algolia/src/__tests__/validateThemeConfig.test.ts +++ b/adapters/docusaurus-theme-search-algolia/src/__tests__/validateThemeConfig.test.ts @@ -23,7 +23,7 @@ const minimalDocSearchConfig = { } satisfies DocSearchInput; const minimalAskAiConfig = { - assistantId: 'my-assistant-id', + agentId: 'my-assistant-id', } satisfies NonNullable['askAi']; const askAiConfigWithIndices = { diff --git a/adapters/docusaurus-theme-search-algolia/src/theme-search-algolia.d.ts b/adapters/docusaurus-theme-search-algolia/src/theme-search-algolia.d.ts index 32e3ff52..9d0a94bf 100644 --- a/adapters/docusaurus-theme-search-algolia/src/theme-search-algolia.d.ts +++ b/adapters/docusaurus-theme-search-algolia/src/theme-search-algolia.d.ts @@ -47,7 +47,7 @@ declare module '@docsearch/docusaurus-adapter' { }; export type AskAiConfig = { - assistantId: DocSearchAskAi['assistantId']; + agentId: DocSearchAskAi['agentId']; suggestedQuestions?: DocSearchAskAi['suggestedQuestions']; searchParameters?: AgentStudioSearchParameters; indices?: AgentStudioIndices[]; diff --git a/adapters/docusaurus-theme-search-algolia/src/theme/SearchBar/index.tsx b/adapters/docusaurus-theme-search-algolia/src/theme/SearchBar/index.tsx index 33531b6f..af6b049f 100644 --- a/adapters/docusaurus-theme-search-algolia/src/theme/SearchBar/index.tsx +++ b/adapters/docusaurus-theme-search-algolia/src/theme/SearchBar/index.tsx @@ -79,6 +79,8 @@ type AdapterDocSearchProps = Omit< | 'onAskAiToggle' | 'onClose' > & { + appId: DocSearchProps['appId']; + apiKey: DocSearchProps['apiKey']; askAi?: AskAiOptions; contextualSearch?: boolean; externalUrlRegex?: string; @@ -422,10 +424,9 @@ function DocSearch({ {...panelOptions} variant={panelOptions.variant ?? 'inline'} pushSelector={panelOptions.pushSelector ?? '#__docusaurus'} - assistantId={sidePanelAskAi.assistantId} + agentId={sidePanelAskAi.agentId} apiKey={sidePanelAskAi.apiKey} appId={sidePanelAskAi.appId} - indexName={sidePanelAskAi.indexName} searchParameters={sidePanelAskAi.searchParameters} indices={panelOptions.indices ?? sidePanelAskAi.indices} suggestedQuestions={ @@ -454,6 +455,8 @@ export default function SearchBar( return ( diff --git a/adapters/docusaurus-theme-search-algolia/src/validateThemeConfig.ts b/adapters/docusaurus-theme-search-algolia/src/validateThemeConfig.ts index 1953ace7..4bdcea4b 100644 --- a/adapters/docusaurus-theme-search-algolia/src/validateThemeConfig.ts +++ b/adapters/docusaurus-theme-search-algolia/src/validateThemeConfig.ts @@ -131,7 +131,7 @@ const AskAiPromptSuggestionsSchema = Joi.object({ }).unknown(false); const AskAiSchema = Joi.object({ - assistantId: Joi.string().required(), + agentId: Joi.string().required(), suggestedQuestions: Joi.boolean().optional(), searchParameters: Joi.object() .pattern(Joi.string(), SearchParametersSchema) @@ -261,7 +261,7 @@ function assertNoRemovedKeys(themeConfig: ThemeConfig): void { const askAi = docsearchRecord.askAi; if (typeof askAi === 'string') { throw new Error( - '`themeConfig.docsearch.askAi` must be an object with `assistantId`.' + '`themeConfig.docsearch.askAi` must be an object with `agentId`.' ); } diff --git a/e2e/a11y.test.ts b/e2e/a11y.test.ts index c20d5989..a237468b 100644 --- a/e2e/a11y.test.ts +++ b/e2e/a11y.test.ts @@ -30,7 +30,7 @@ test.describe('a11y > Modal', () => { await expect(docSearch.hits).toBeVisible(); const scanResults = await axe() - .include('#docsearch-hits_docsearch-list') + .include('#docsearch-hits_docsearch_0-list') .analyze(); await testInfo.attach('a11y-scan-results-modal-search-results', { diff --git a/e2e/fixtures.ts b/e2e/fixtures.ts index bfb83477..d458cb86 100644 --- a/e2e/fixtures.ts +++ b/e2e/fixtures.ts @@ -24,7 +24,7 @@ export class DocSearchPage { this.hits = page.locator('.DocSearch-Hits').first(); this.clearButton = page.locator('.DocSearch-Clear'); this.firstHit = page - .locator('#docsearch-hits_docsearch-list .DocSearch-Hit a') + .locator('#docsearch-hits_docsearch_0-list .DocSearch-Hit a') .first(); } diff --git a/e2e/search.spec.ts b/e2e/search.spec.ts index 4574a7bb..25dfe71b 100644 --- a/e2e/search.spec.ts +++ b/e2e/search.spec.ts @@ -113,7 +113,7 @@ test.describe('Search', () => { await docSearch.typeQueryMatching(); await expect(docSearch.hits).toBeVisible(); const firstHitOption = page - .locator('#docsearch-hits_docsearch-list') + .locator('#docsearch-hits_docsearch_0-list') .getByRole('option') .first(); const firstHitOptionId = await firstHitOption.getAttribute('id'); diff --git a/examples/demo-js/src/main.ts b/examples/demo-js/src/main.ts index d01cd15e..0d59115a 100644 --- a/examples/demo-js/src/main.ts +++ b/examples/demo-js/src/main.ts @@ -36,10 +36,9 @@ let sidepanelInstance: SidepanelInstance | undefined = undefined; sidepanelInstance = sidepanel({ container: '#docsearch-sidepanel', - indexName: 'docsearch', appId: 'PMZUYBQDAK', apiKey: '24b09689d5b4223813d9b8e48563c8f6', - assistantId: 'ccdec697-e3fe-465b-a1c3-657e7bf18aef', + agentId: 'ccdec697-e3fe-465b-a1c3-657e7bf18aef', onReady: () => { // eslint-disable-next-line no-console console.log('[demo-js] sidepanel onReady()'); @@ -73,9 +72,7 @@ docsearchInstance = docsearch({ indices: ['docsearch'], appId: 'PMZUYBQDAK', apiKey: '24b09689d5b4223813d9b8e48563c8f6', - askAi: { - assistantId: 'ccdec697-e3fe-465b-a1c3-657e7bf18aef', - }, + askAi: 'ccdec697-e3fe-465b-a1c3-657e7bf18aef', interceptAskAiEvent: (initialMessage) => { docsearchInstance?.close(); sidepanelInstance.open(initialMessage); diff --git a/examples/demo-react/src/constants.ts b/examples/demo-react/src/constants.ts new file mode 100644 index 00000000..df256b77 --- /dev/null +++ b/examples/demo-react/src/constants.ts @@ -0,0 +1,4 @@ +export const APP_ID = 'PMZUYBQDAK'; +export const API_KEY = '24b09689d5b4223813d9b8e48563c8f6'; +export const AGENT_ID = 'ccdec697-e3fe-465b-a1c3-657e7bf18aef'; +export const SEARCH_INDEX_NAME = 'docsearch'; diff --git a/examples/demo-react/src/examples/basic-askai.tsx b/examples/demo-react/src/examples/basic-askai.tsx index 469b6159..66a4d00f 100644 --- a/examples/demo-react/src/examples/basic-askai.tsx +++ b/examples/demo-react/src/examples/basic-askai.tsx @@ -3,6 +3,7 @@ import { DocSearchAI, type ToolCalls } from '@docsearch/react'; import type { JSX } from 'react'; import type { DemoTheme } from '../App'; +import { AGENT_ID, API_KEY, APP_ID, SEARCH_INDEX_NAME } from '../constants'; const customTools: ToolCalls = { printConsoleMessage: { @@ -29,11 +30,11 @@ export default function BasicAskAI({ }): JSX.Element { return ( + - + ); } diff --git a/examples/demo-react/src/examples/default.tsx b/examples/demo-react/src/examples/default.tsx index 0506bfd7..e00d9282 100644 --- a/examples/demo-react/src/examples/default.tsx +++ b/examples/demo-react/src/examples/default.tsx @@ -3,6 +3,7 @@ import { DocSearch } from '@docsearch/react'; import type { JSX } from 'react'; import type { DemoTheme } from '../App'; +import { API_KEY, APP_ID, SEARCH_INDEX_NAME } from '../constants'; export default function DefaultExperience({ theme, @@ -11,9 +12,9 @@ export default function DefaultExperience({ }): JSX.Element { return ( ); diff --git a/examples/demo-react/src/examples/dynamic-import-modal.tsx b/examples/demo-react/src/examples/dynamic-import-modal.tsx index dd7b4c55..5477d961 100644 --- a/examples/demo-react/src/examples/dynamic-import-modal.tsx +++ b/examples/demo-react/src/examples/dynamic-import-modal.tsx @@ -6,6 +6,7 @@ import { useCallback, useRef, useState, type JSX } from 'react'; import { createPortal } from 'react-dom'; import type { DemoTheme } from '../App'; +import { AGENT_ID, API_KEY, APP_ID, SEARCH_INDEX_NAME } from '../constants'; let DocSearchModal: typeof DocSearchAskAiModalType | null = null; @@ -94,12 +95,10 @@ function DocSearch({ theme }: { theme: DemoTheme }): JSX.Element { searchContainer.current && createPortal( + - + - + ); } diff --git a/examples/demo-react/src/examples/multi-index.tsx b/examples/demo-react/src/examples/multi-index.tsx index 8b50eeb8..ed771d52 100644 --- a/examples/demo-react/src/examples/multi-index.tsx +++ b/examples/demo-react/src/examples/multi-index.tsx @@ -3,6 +3,7 @@ import { DocSearch } from '@docsearch/react'; import type { JSX } from 'react'; import type { DemoTheme } from '../App'; +import { API_KEY, APP_ID, SEARCH_INDEX_NAME } from '../constants'; export default function MultiIndex({ theme, @@ -13,7 +14,7 @@ export default function MultiIndex({ + { return items.map((item: any) => ({ diff --git a/packages/docsearch-core/src/DocSearch.tsx b/packages/docsearch-core/src/DocSearch.tsx index e1083bff..8886bc93 100644 --- a/packages/docsearch-core/src/DocSearch.tsx +++ b/packages/docsearch-core/src/DocSearch.tsx @@ -48,6 +48,10 @@ export interface DocSearchRef { } export interface DocSearchContext { + /** Default Algolia application ID for child DocSearch/Sidepanel components. */ + appId?: string; + /** Default Algolia API key for child DocSearch/Sidepanel components. */ + apiKey?: string; docsearchState: DocSearchState; setDocsearchState: (newState: DocSearchState) => void; searchButtonRef: React.RefObject; @@ -79,6 +83,10 @@ export interface DocSearchCallbacks { export interface DocSearchProps extends DocSearchCallbacks { children: Array | JSX.Element | React.ReactNode | null; + /** Default Algolia application id for descendant DocSearch views. */ + appId?: string; + /** Default Algolia API key for descendant DocSearch views. */ + apiKey?: string; theme?: DocSearchTheme; initialQuery?: string; keyboardShortcuts?: KeyboardShortcuts; @@ -90,6 +98,8 @@ Context.displayName = 'DocSearchContext'; function DocSearchInner( { children, + appId, + apiKey, theme, onReady, onOpen, @@ -267,6 +277,8 @@ function DocSearchInner( const value: DocSearchContext = React.useMemo( () => ({ + appId, + apiKey, docsearchState, setDocsearchState, searchButtonRef, @@ -282,6 +294,8 @@ function DocSearchInner( isHybridModeSupported, }), [ + appId, + apiKey, docsearchState, searchButtonRef, initialQuery, diff --git a/packages/docsearch-core/src/__tests__/DocSearch.test.tsx b/packages/docsearch-core/src/__tests__/DocSearch.test.tsx index 6f807c65..9defab72 100644 --- a/packages/docsearch-core/src/__tests__/DocSearch.test.tsx +++ b/packages/docsearch-core/src/__tests__/DocSearch.test.tsx @@ -67,6 +67,18 @@ describe('@docsearch/core', () => { expect(screen.getByText('State: ready')).toBeInTheDocument(); }); + it('provides credential defaults to children', () => { + const { result } = renderCustomHook({ + appId: 'app-id', + apiKey: 'api-key', + }); + + expect(result.current).toMatchObject({ + appId: 'app-id', + apiKey: 'api-key', + }); + }); + it('updates state from children', async () => { const Comp = (): JSX.Element => { const { docsearchState, openModal } = useDocSearch(); diff --git a/packages/docsearch-modal/src/DocSearchAskAiModal.tsx b/packages/docsearch-modal/src/DocSearchAskAiModal.tsx index debdb3af..7ded52c9 100644 --- a/packages/docsearch-modal/src/DocSearchAskAiModal.tsx +++ b/packages/docsearch-modal/src/DocSearchAskAiModal.tsx @@ -7,6 +7,8 @@ import { createPortal } from 'react-dom'; export type DocSearchAskAiModalProps = Omit< ReactDocSearchAskAiModalProps, + | 'appId' + | 'apiKey' | 'initialScrollY' | 'isAskAiActive' | 'isHybridModeSupported' @@ -14,12 +16,15 @@ export type DocSearchAskAiModalProps = Omit< | 'onAskAiToggle' | 'onClose' | 'theme' ->; +> & + Partial>; export function DocSearchAskAiModal( props: DocSearchAskAiModalProps ): JSX.Element | null { const { + appId: providerAppId, + apiKey: providerApiKey, isModalActive, onAskAiToggle, closeModal, @@ -29,6 +34,15 @@ export function DocSearchAskAiModal( isHybridModeSupported, } = useDocSearch(); + const appId = props.appId ?? providerAppId; + const apiKey = props.apiKey ?? providerApiKey; + + if (!appId || !apiKey) { + throw new Error( + '`DocSearchAskAiModal` requires `appId` and `apiKey` props or values configured on the `DocSearch` provider.' + ); + } + const containerElement = React.useMemo( () => props.portalContainer ?? document.body, [props.portalContainer] @@ -43,6 +57,8 @@ export function DocSearchAskAiModal( const modalProps: ReactDocSearchAskAiModalProps = React.useMemo( () => ({ ...props, + appId, + apiKey, isAskAiActive, initialQuery: props.initialQuery ?? initialQuery, initialScrollY: initialScroll, @@ -52,6 +68,8 @@ export function DocSearchAskAiModal( }), [ props, + appId, + apiKey, isAskAiActive, initialQuery, initialScroll, diff --git a/packages/docsearch-modal/src/DocSearchModal.tsx b/packages/docsearch-modal/src/DocSearchModal.tsx index 9ee60d52..142e3c6a 100644 --- a/packages/docsearch-modal/src/DocSearchModal.tsx +++ b/packages/docsearch-modal/src/DocSearchModal.tsx @@ -7,12 +7,33 @@ import { createPortal } from 'react-dom'; export type DocSearchModalProps = Omit< ReactDocSearchModalProps, - 'initialScrollY' | 'keyboardShortcuts' | 'onClose' | 'theme' ->; + | 'appId' + | 'apiKey' + | 'initialScrollY' + | 'keyboardShortcuts' + | 'onClose' + | 'theme' +> & + Partial>; export function DocSearchModal(props: DocSearchModalProps): JSX.Element | null { - const { isModalActive, closeModal, initialQuery, registerView } = - useDocSearch(); + const { + appId: providerAppId, + apiKey: providerApiKey, + isModalActive, + closeModal, + initialQuery, + registerView, + } = useDocSearch(); + + const appId = props.appId ?? providerAppId; + const apiKey = props.apiKey ?? providerApiKey; + + if (!appId || !apiKey) { + throw new Error( + '`DocSearchModal` requires `appId` and `apiKey` props or values configured on the `DocSearch` provider.' + ); + } const containerElement = React.useMemo( () => props.portalContainer ?? document.body, @@ -28,11 +49,13 @@ export function DocSearchModal(props: DocSearchModalProps): JSX.Element | null { const modalProps: ReactDocSearchModalProps = React.useMemo( () => ({ ...props, + appId, + apiKey, initialQuery: props.initialQuery ?? initialQuery, initialScrollY: initialScroll, onClose: closeModal, }), - [props, initialQuery, initialScroll, closeModal] + [props, appId, apiKey, initialQuery, initialScroll, closeModal] ); return isModalActive diff --git a/packages/docsearch-modal/src/__tests__/DocSearchModal.test.tsx b/packages/docsearch-modal/src/__tests__/DocSearchModal.test.tsx index 87f636db..8d6fa97f 100644 --- a/packages/docsearch-modal/src/__tests__/DocSearchModal.test.tsx +++ b/packages/docsearch-modal/src/__tests__/DocSearchModal.test.tsx @@ -47,6 +47,11 @@ const DEFAULT_PROPS: DocSearchModalProps = { }, }; +const PROVIDER_DEFAULT_PROPS: DocSearchModalProps = { + indices: [INDEX_NAME], + transformSearchClient: DEFAULT_PROPS.transformSearchClient, +}; + const renderComponent = ( props: DocSearchModalProps = DEFAULT_PROPS, docsearchProps: Omit = {} @@ -67,6 +72,22 @@ describe('@docsearch/modal', () => { renderComponent(); }); + it('uses credentials configured on the provider', () => { + renderComponent(PROVIDER_DEFAULT_PROPS, { + appId: APP_ID, + apiKey: API_KEY, + }); + + act(() => { + fireEvent.keyDown(document, { + key: 'k', + ctrlKey: true, + }); + }); + + expect(screen.getByText('Search')).toBeInTheDocument(); + }); + it('opens modal on keyboard shortcut', () => { renderComponent(); diff --git a/packages/docsearch-react/src/DocSearch.tsx b/packages/docsearch-react/src/DocSearch.tsx index 2276f904..db8bf842 100644 --- a/packages/docsearch-react/src/DocSearch.tsx +++ b/packages/docsearch-react/src/DocSearch.tsx @@ -157,11 +157,11 @@ export interface DocSearchProps { } function DocSearchComponent( - props: DocSearchProps, + { appId, apiKey, ...props }: DocSearchProps, ref: React.ForwardedRef ): JSX.Element { return ( - + ); @@ -169,7 +169,9 @@ function DocSearchComponent( export const DocSearch = React.forwardRef(DocSearchComponent); -export function DocSearchInner(props: DocSearchProps): JSX.Element { +export function DocSearchInner( + props: Omit +): JSX.Element { const { searchButtonRef, keyboardShortcuts, @@ -177,8 +179,14 @@ export function DocSearchInner(props: DocSearchProps): JSX.Element { initialQuery, openModal, closeModal, + appId, + apiKey, } = useDocSearch(); + if (!appId || !apiKey) { + throw new Error('`DocSearch` requires `appId` and `apiKey` props.'); + } + return ( <> ): JSX.Element { return ( - + ); @@ -248,7 +248,9 @@ function DocSearchAIComponent( export const DocSearchAI = React.forwardRef(DocSearchAIComponent); -export function DocSearchAIInner(props: DocSearchAIProps): JSX.Element { +export function DocSearchAIInner( + props: Omit +): JSX.Element { const { searchButtonRef, keyboardShortcuts, @@ -259,8 +261,14 @@ export function DocSearchAIInner(props: DocSearchAIProps): JSX.Element { openModal, closeModal, isHybridModeSupported, + appId, + apiKey, } = useDocSearch(); + if (!appId || !apiKey) { + throw new Error('`DocSearchAI` requires `appId` and `apiKey` props.'); + } + return ( <> ('initial'); const suggestedQuestions = useSuggestedQuestions({ - assistantId: askAiConfigurationId, + agentId: askAiConfigurationId, searchClient, suggestedQuestionsEnabled: askAiConfig?.suggestedQuestions, }); @@ -224,10 +224,9 @@ export function DocSearchAskAiModal({ startNewConversation, restoreConversation, } = useAskAi({ - assistantId: askAiConfigurationId, + agentId: askAiConfigurationId, apiKey: askAiConfig?.apiKey || apiKey, appId: askAiConfig?.appId || appId, - indexName: askAiConfig?.indexName || defaultIndexName, searchParameters: askAiSearchParameters, tools, memory: askAiConfig?.memory, diff --git a/packages/docsearch-react/src/ResultsScreen.tsx b/packages/docsearch-react/src/ResultsScreen.tsx index 6b4195b0..ce6b9269 100644 --- a/packages/docsearch-react/src/ResultsScreen.tsx +++ b/packages/docsearch-react/src/ResultsScreen.tsx @@ -6,6 +6,7 @@ import type { ResultsTranslations } from './Results'; import { Results } from './Results'; import type { ScreenStateProps } from './ScreenState'; import type { InternalDocSearchHit } from './types'; +import { removeHighlightTags } from './utils'; export type ResultsScreenTranslations = Partial<{ askAiPlaceholder: string; @@ -27,19 +28,6 @@ export function ResultsScreen({ resultBadgeKey, ...props }: ResultsScreenProps): JSX.Element { - const { resultsSectionTitle = 'Results' } = translations; - - const renderIcon = React.useCallback( - ({ item }: { item: InternalDocSearchHit }) => { - return ( -
- -
- ); - }, - [] - ); - const renderAction = React.useCallback(() => { return (
@@ -70,14 +58,40 @@ export function ResultsScreen({ return null; } + const title = removeHighlightTags(collection.items[0]); + return ( ( + <> + {item.__docsearch_parent && ( + + + {item.__docsearch_parent !== + collection.items[index + 1]?.__docsearch_parent ? ( + + ) : ( + + )} + + + )} +
+ +
+ + )} renderAction={renderAction} renderResultBadge={renderResultBadge} /> diff --git a/packages/docsearch-react/src/Sidepanel.tsx b/packages/docsearch-react/src/Sidepanel.tsx index d434f392..1a159031 100644 --- a/packages/docsearch-react/src/Sidepanel.tsx +++ b/packages/docsearch-react/src/Sidepanel.tsx @@ -37,17 +37,12 @@ export type SidepanelSearchParameters = { }; export type DocSearchSidepanelProps = DocSearchCallbacks & { - /** The assistant ID to use for the ask AI feature. */ - assistantId: string; - /** Public api key with search permissions for the index. */ + /** The Agent Studio agent ID to use for Ask AI. */ + agentId: string; + /** Search only API key. */ apiKey: string; - /** Algolia application id used by the search client. */ + /** Algolia application ID that hosts the Agent Studio agent. */ appId: string; - /** - * The index name to use for the ask AI feature. Your assistant will search - * this index for relevant documents. - */ - indexName: string; /** * Configuration for keyboard shortcuts. Allows enabling/disabling specific * shortcuts. @@ -103,6 +98,8 @@ function DocSearchSidepanelComponent( ): JSX.Element { return ( { setDocsearchState(docsearchState === 'sidepanel' ? 'ready' : 'sidepanel'); }, [docsearchState, setDocsearchState]); diff --git a/packages/docsearch-react/src/Sidepanel/Sidepanel.tsx b/packages/docsearch-react/src/Sidepanel/Sidepanel.tsx index 3bac7e0f..bbd2fa3a 100644 --- a/packages/docsearch-react/src/Sidepanel/Sidepanel.tsx +++ b/packages/docsearch-react/src/Sidepanel/Sidepanel.tsx @@ -134,10 +134,9 @@ function SidepanelInner( isOpen = false, onOpen, onClose, - assistantId, + agentId, apiKey, appId, - indexName, variant = 'floating', searchParameters, pushSelector, @@ -194,8 +193,7 @@ function SidepanelInner( restoreConversation, } = useAskAi({ appId, - indexName, - assistantId, + agentId, apiKey, searchParameters, tools, @@ -204,7 +202,7 @@ function SidepanelInner( }); const suggestedQuestions = useSuggestedQuestions({ - assistantId, + agentId, suggestedQuestionsEnabled, searchClient, }); diff --git a/packages/docsearch-react/src/__tests__/api.test.tsx b/packages/docsearch-react/src/__tests__/api.test.tsx index 6aae7833..fe3beb5e 100644 --- a/packages/docsearch-react/src/__tests__/api.test.tsx +++ b/packages/docsearch-react/src/__tests__/api.test.tsx @@ -445,7 +445,7 @@ describe('api', () => { }, }} askAi={{ - assistantId: '123', + agentId: '123', promptSuggestions: { indexName: 'prompt-suggestions', }, diff --git a/packages/docsearch-react/src/__tests__/resultsScreen.test.tsx b/packages/docsearch-react/src/__tests__/resultsScreen.test.tsx new file mode 100644 index 00000000..4c78ccda --- /dev/null +++ b/packages/docsearch-react/src/__tests__/resultsScreen.test.tsx @@ -0,0 +1,126 @@ +import { cleanup, render } from '@testing-library/react'; +import React, { type JSX } from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import '@testing-library/jest-dom/vitest'; + +import { ResultsScreen } from '../ResultsScreen'; +import type { InternalDocSearchHit } from '../types'; + +function Hit({ children }: { children: React.ReactNode }): JSX.Element { + return <>{children}; +} + +function createHit( + objectID: string, + lvl0: string, + parent: InternalDocSearchHit | null = null +): InternalDocSearchHit { + const hit: InternalDocSearchHit = { + objectID, + content: null, + url: `/${objectID}`, + url_without_anchor: `/${objectID}`, + type: parent ? 'content' : 'lvl1', + anchor: null, + hierarchy: { + lvl0, + lvl1: 'Installation', + lvl2: null, + lvl3: null, + lvl4: null, + lvl5: null, + lvl6: null, + }, + _highlightResult: { + content: { value: '', matchLevel: 'none', matchedWords: [] }, + hierarchy: { + lvl0: { value: lvl0, matchLevel: 'none', matchedWords: [] }, + lvl1: { value: 'Installation', matchLevel: 'none', matchedWords: [] }, + lvl2: { value: '', matchLevel: 'none', matchedWords: [] }, + lvl3: { value: '', matchLevel: 'none', matchedWords: [] }, + lvl4: { value: '', matchLevel: 'none', matchedWords: [] }, + lvl5: { value: '', matchLevel: 'none', matchedWords: [] }, + lvl6: { value: '', matchLevel: 'none', matchedWords: [] }, + }, + hierarchy_camel: [], + }, + _snippetResult: { + content: { value: '', matchLevel: 'none' }, + hierarchy: { + lvl0: { value: lvl0, matchLevel: 'none', matchedWords: [] }, + lvl1: { value: 'Installation', matchLevel: 'none', matchedWords: [] }, + lvl2: { value: '', matchLevel: 'none', matchedWords: [] }, + lvl3: { value: '', matchLevel: 'none', matchedWords: [] }, + lvl4: { value: '', matchLevel: 'none', matchedWords: [] }, + lvl5: { value: '', matchLevel: 'none', matchedWords: [] }, + lvl6: { value: '', matchLevel: 'none', matchedWords: [] }, + }, + hierarchy_camel: [], + }, + __docsearch_parent: parent, + }; + + return hit; +} + +afterEach(() => { + cleanup(); +}); + +describe('ResultsScreen', () => { + it('renders lvl0 headings and tree connectors for grouped child hits', () => { + const parent = createHit('guide-install', 'Guides'); + const firstChild = createHit('guide-install-1', 'Guides', parent); + const lastChild = createHit('guide-install-2', 'Guides', parent); + const reference = createHit('reference-install', 'Reference'); + + const props = { + state: { + activeItemId: null, + collections: [ + { + source: { sourceId: 'hits_docs_0' }, + items: [parent, firstChild, lastChild], + }, + { + source: { sourceId: 'hits_docs_1' }, + items: [reference], + }, + ], + completion: null, + context: {}, + isOpen: true, + query: 'install', + status: 'idle', + }, + getItemProps: vi.fn(() => ({})), + getListProps: vi.fn(() => ({})), + hitComponent: Hit, + indexName: 'docs', + inputRef: React.createRef(), + onItemClick: vi.fn(), + refresh: vi.fn(), + setQuery: vi.fn(), + recentSearches: { add: vi.fn(), remove: vi.fn() }, + favoriteSearches: { add: vi.fn(), remove: vi.fn() }, + conversations: { add: vi.fn(), remove: vi.fn() }, + disableUserPersonalization: false, + hasCollections: true, + } as any; + + render(); + + expect(document.querySelector('.DocSearch-Hit-source')).toHaveTextContent( + 'Guides' + ); + expect( + document.querySelectorAll('.DocSearch-Hit-source')[1] + ).toHaveTextContent('Reference'); + + const connectorPaths = Array.from( + document.querySelectorAll('.DocSearch-Hit-Tree path') + ).map((path) => path.getAttribute('d')); + + expect(connectorPaths).toEqual(['M8 6v42M20 27H8.3', 'M8 6v21M20 27H8.3']); + }); +}); diff --git a/packages/docsearch-react/src/__tests__/useAskAi.test.ts b/packages/docsearch-react/src/__tests__/useAskAi.test.ts index 54d3d68d..156029bd 100644 --- a/packages/docsearch-react/src/__tests__/useAskAi.test.ts +++ b/packages/docsearch-react/src/__tests__/useAskAi.test.ts @@ -157,8 +157,7 @@ describe('useAskAi', () => { useAskAi({ apiKey: 'api-key', appId: 'app-id', - assistantId: 'assistant-id', - indexName: 'index-name', + agentId: 'assistant-id', tools: { customAction: { onToolCall, @@ -205,8 +204,7 @@ describe('useAskAi', () => { useAskAi({ apiKey: 'api-key', appId: 'app-id', - assistantId: 'assistant-id', - indexName: 'index-name', + agentId: 'assistant-id', tools: { customAction: { onToolCall, @@ -233,10 +231,9 @@ describe('useAskAi', () => { useAskAi({ apiKey: 'api-key', appId: 'app-id', - assistantId: 'assistant-id', - indexName: 'index-name', - tools: {}, + agentId: 'assistant-id', memory: { userToken: 'secure-user-token' }, + tools: {}, }) ); @@ -250,8 +247,7 @@ describe('useAskAi', () => { useAskAi({ apiKey: 'api-key', appId: 'app-id', - assistantId: 'assistant-id', - indexName: 'index-name', + agentId: 'assistant-id', tools: {}, }) ); @@ -266,8 +262,7 @@ describe('useAskAi', () => { useAskAi({ apiKey: 'api-key', appId: 'app-id', - assistantId: 'assistant-id', - indexName: 'index-name', + agentId: 'assistant-id', tools: {}, }) ); @@ -284,8 +279,7 @@ describe('useAskAi', () => { useAskAi({ apiKey: 'api-key', appId: 'app-id', - assistantId: 'assistant-id', - indexName: 'index-name', + agentId: 'assistant-id', tools: {}, searchParameters, }) @@ -308,8 +302,7 @@ describe('useAskAi', () => { useAskAi({ apiKey: 'api-key', appId: 'app-id', - assistantId: 'assistant-id', - indexName: 'index-name', + agentId: 'assistant-id', tools: {}, indices, }) @@ -335,8 +328,7 @@ describe('useAskAi', () => { useAskAi({ apiKey: 'api-key', appId: 'app-id', - assistantId: 'assistant-id', - indexName: 'index-name', + agentId: 'assistant-id', tools: {}, searchParameters, indices, @@ -353,8 +345,7 @@ describe('useAskAi', () => { useAskAi({ apiKey: 'api-key', appId: 'app-id', - assistantId: 'assistant-id', - indexName: 'index-name', + agentId: 'assistant-id', tools: {}, indices: [], }) @@ -369,8 +360,7 @@ describe('useAskAi', () => { useAskAi({ apiKey: 'api-key', appId: 'app-id', - assistantId: 'assistant-id', - indexName: 'index-name', + agentId: 'assistant-id', tools: {}, }) ); @@ -383,8 +373,7 @@ describe('useAskAi', () => { useAskAi({ apiKey: 'api-key', appId: 'app-id', - assistantId: 'assistant-id', - indexName: 'index-name', + agentId: 'assistant-id', tools: {}, }) ); @@ -408,8 +397,7 @@ describe('useAskAi', () => { useAskAi({ apiKey: 'api-key', appId: 'app-id', - assistantId: 'assistant-id', - indexName: 'index-name', + agentId: 'assistant-id', tools: {}, }) ); @@ -434,8 +422,7 @@ describe('useAskAi', () => { useAskAi({ apiKey: 'api-key', appId: 'app-id', - assistantId: 'assistant-id', - indexName: 'index-name', + agentId: 'assistant-id', tools: {}, }) ); @@ -463,8 +450,7 @@ describe('useAskAi', () => { useAskAi({ apiKey: 'api-key', appId: 'app-id', - assistantId: 'assistant-id', - indexName: 'index-name', + agentId: 'assistant-id', tools: {}, }) ); @@ -492,8 +478,7 @@ describe('useAskAi', () => { useAskAi({ apiKey: 'api-key', appId: 'app-id', - assistantId: 'assistant-id', - indexName: 'index-name', + agentId: 'assistant-id', tools: {}, }) ); @@ -506,8 +491,7 @@ describe('useAskAi', () => { useAskAi({ apiKey: 'api-key', appId: 'app-id', - assistantId: 'assistant-id', - indexName: 'index-name', + agentId: 'assistant-id', tools: {}, }) ); @@ -541,8 +525,7 @@ describe('useAskAi', () => { useAskAi({ apiKey: 'api-key', appId: 'app-id', - assistantId: 'assistant-id', - indexName: 'index-name', + agentId: 'assistant-id', tools: {}, }) ); @@ -578,8 +561,7 @@ describe('useAskAi', () => { useAskAi({ apiKey: 'api-key', appId: 'app-id', - assistantId: 'assistant-id', - indexName: 'index-name', + agentId: 'assistant-id', searchParameters, indices, tools: {}, diff --git a/packages/docsearch-react/src/__tests__/utils.test.ts b/packages/docsearch-react/src/__tests__/utils.test.ts index 42cc9b16..11d14932 100644 --- a/packages/docsearch-react/src/__tests__/utils.test.ts +++ b/packages/docsearch-react/src/__tests__/utils.test.ts @@ -3,7 +3,11 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import type { AIMessage } from '../types/AskiAi'; import { extractLinksFromMessage } from '../utils/ai'; import { createFacetFilters } from '../utils/createDocSearchSources'; -import { getFacetLabel, normalizeFacets } from '../utils/facets'; +import { + deriveDefaultSelectedFacetsFromIndex, + getFacetLabel, + normalizeFacets, +} from '../utils/facets'; import { createObjectStorage, createStorage, @@ -66,6 +70,34 @@ describe('utils', () => { ); }); + it('derives default selections from index facet filters', () => { + expect( + deriveDefaultSelectedFacetsFromIndex([ + { name: 'docs' }, + { + name: 'blog', + searchParameters: { + facetFilters: [ + 'language:en', + 'version:v2', + 'invalid', + 'empty:', + ':value', + ], + }, + }, + { + name: 'api', + searchParameters: { facetFilters: ['language:fr'] }, + }, + { + name: 'guides', + searchParameters: { facetFilters: 'format:guide' as never }, + }, + ]) + ).toEqual({ language: 'fr', version: 'v2', format: 'guide' }); + }); + it('returns configured facetFilters when no dynamic facets are selected', () => { expect(createFacetFilters(['language:en'], {})).toEqual(['language:en']); }); @@ -79,10 +111,21 @@ describe('utils', () => { ).toEqual(['docusaurus_tag:default', 'language:en', 'version:v2']); }); - it('ignores empty dynamic facet selections', () => { + it('overrides configured filters with dynamic selections for the same facet', () => { expect( - createFacetFilters(undefined, { language: '', version: 'v2' }) - ).toEqual(['version:v2']); + createFacetFilters(['language:en', 'version:v2'], { + language: 'fr', + }) + ).toEqual(['version:v2', 'language:fr']); + }); + + it('removes configured filters for cleared facet selections', () => { + expect( + createFacetFilters(['language:en', 'version:v2'], { + language: '', + type: 'guide', + }) + ).toEqual(['version:v2', 'type:guide']); }); }); diff --git a/packages/docsearch-react/src/components/FacetBar.tsx b/packages/docsearch-react/src/components/FacetBar.tsx index 4ac91b56..e9784c5d 100644 --- a/packages/docsearch-react/src/components/FacetBar.tsx +++ b/packages/docsearch-react/src/components/FacetBar.tsx @@ -3,6 +3,7 @@ import React, { type JSX } from 'react'; import type { DocSearchFacet } from '../DocSearch'; import { ChevronIcon } from '../icons'; import { capitalize } from '../utils'; +import type { FacetSelections } from '../utils/createDocSearchSources'; import { getFacetLabel } from '../utils/facets'; import { Chip } from './ui/Chip'; @@ -132,7 +133,7 @@ const SelectedFacetChip = React.memo(function SelectedFacetChip({ value, dismissAriaLabel, onDismiss, -}: SelectedFacetChipProps): JSX.Element { +}: SelectedFacetChipProps): JSX.Element | null { const handleDismissFacet = React.useCallback( (e: React.MouseEvent) => { onDismiss(facetKey, e); @@ -153,7 +154,7 @@ const SelectedFacetChip = React.memo(function SelectedFacetChip({ interface FacetBarProps { facets: FacetBarFacet[]; - selections: Record; + selections: FacetSelections; onSelectionChange: (facet: string, value: string) => void; clearSelections: () => void; translations?: FacetBarTranslations; @@ -174,9 +175,15 @@ export const FacetBar = React.memo(function FacetBar({ selectedFacetsAriaLabel = 'Selected search filters', clearFacetAriaLabel = 'Clear filter:', } = translations; + const visibleFacetKeys = React.useMemo( + () => new Set(facets.map((f) => f.key)), + [facets] + ); const selectionsToDisplay = React.useMemo(() => { - return Object.entries(selections).filter(([_, value]) => Boolean(value)); - }, [selections]); + return Object.entries(selections).filter( + ([key, value]) => Boolean(value) && visibleFacetKeys.has(key) + ); + }, [selections, visibleFacetKeys]); const triggerRefs = React.useRef(new Map()); diff --git a/packages/docsearch-react/src/hooks/__tests__/useDocSearchFacets.test.tsx b/packages/docsearch-react/src/hooks/__tests__/useDocSearchFacets.test.tsx index dcc03c5f..13323a2c 100644 --- a/packages/docsearch-react/src/hooks/__tests__/useDocSearchFacets.test.tsx +++ b/packages/docsearch-react/src/hooks/__tests__/useDocSearchFacets.test.tsx @@ -103,8 +103,8 @@ describe('useDocSearchFacets', () => { result.current.clearFacetSelections(); }); - expect(result.current.facetSelections).toEqual({}); - expect(result.current.facetSelectionsRef.current).toEqual({}); + expect(result.current.facetSelections).toEqual({ language: '' }); + expect(result.current.facetSelectionsRef.current).toEqual({ language: '' }); expect(onSelectionsChange).toHaveBeenCalledTimes(2); }); diff --git a/packages/docsearch-react/src/hooks/useDocSearchFacets.ts b/packages/docsearch-react/src/hooks/useDocSearchFacets.ts index 3f84c5e2..f0fe5eec 100644 --- a/packages/docsearch-react/src/hooks/useDocSearchFacets.ts +++ b/packages/docsearch-react/src/hooks/useDocSearchFacets.ts @@ -5,7 +5,10 @@ import type { DocSearchFacet, DocSearchIndex } from '../DocSearch'; import { useFacetValues } from '../useFacetValues'; import type { useSearchClient } from '../useSearchClient'; import type { FacetSelections } from '../utils/createDocSearchSources'; -import { normalizeFacets } from '../utils/facets'; +import { + deriveDefaultSelectedFacetsFromIndex, + normalizeFacets, +} from '../utils/facets'; export interface UseDocSearchFacetsProps { facets?: DocSearchFacet[]; @@ -38,18 +41,18 @@ export function useDocSearchFacets({ () => normalizeFacets(facets), [facets] ); + const normalizedFacetsRef = React.useRef(normalizedFacets); const facetValues = useFacetValues({ facets: normalizedFacets, indexes, searchClient, }); const [facetSelections, setFacetSelections] = React.useState( - {} + () => deriveDefaultSelectedFacetsFromIndex(indexes) ); const facetSelectionsRef = React.useRef(facetSelections); const onSelectionsChangeRef = React.useRef(onSelectionsChange); - onSelectionsChangeRef.current = onSelectionsChange; const visibleFacets = React.useMemo( () => @@ -59,6 +62,11 @@ export function useDocSearchFacets({ [facetValues, normalizedFacets] ); + React.useLayoutEffect(() => { + normalizedFacetsRef.current = normalizedFacets; + onSelectionsChangeRef.current = onSelectionsChange; + }); + const applySelections = React.useCallback((next: FacetSelections): void => { facetSelectionsRef.current = next; setFacetSelections(next); @@ -67,15 +75,13 @@ export function useDocSearchFacets({ const handleFacetSelectionChange = React.useCallback( (facet: string, value: string): void => { - if (facetSelectionsRef.current[facet] === value) return; + const existing = facetSelectionsRef.current[facet]; + + if (existing === value) return; const next = { ...facetSelectionsRef.current }; - if (value === '') { - delete next[facet]; - } else { - next[facet] = value; - } + next[facet] = value; applySelections(next); }, @@ -84,7 +90,15 @@ export function useDocSearchFacets({ const clearFacetSelections = React.useCallback(() => { if (Object.keys(facetSelectionsRef.current).length === 0) return; - applySelections({}); + const next = {}; + + for (const facet of normalizedFacetsRef.current) { + if (facetSelectionsRef.current[facet.key]) { + next[facet.key] = ''; + } + } + + applySelections(next); }, [applySelections]); return { diff --git a/packages/docsearch-react/src/useAskAi.ts b/packages/docsearch-react/src/useAskAi.ts index 2994ab6f..73dc6105 100644 --- a/packages/docsearch-react/src/useAskAi.ts +++ b/packages/docsearch-react/src/useAskAi.ts @@ -30,10 +30,9 @@ import type { type UseChat = UseChatHelpers; type UseAskAiParams = { - assistantId: string; + agentId: string; apiKey: string; appId: string; - indexName: string; searchParameters?: AgentStudioSearchParameters; tools: ToolCalls; memory?: Memory; @@ -71,7 +70,7 @@ type UseAskAi = (params: UseAskAiParams) => UseAskAiReturn; type AgentStudioTransportParams = Pick< UseAskAiParams, - 'apiKey' | 'appId' | 'assistantId' + 'apiKey' | 'appId' | 'agentId' > & { searchParameters?: AgentStudioSearchParameters; userToken?: string; @@ -81,7 +80,7 @@ type AgentStudioTransportParams = Pick< const getAgentStudioTransport = ({ appId, apiKey, - assistantId, + agentId, searchParameters, userToken, indices, @@ -100,7 +99,7 @@ const getAgentStudioTransport = ({ } return new DefaultChatTransport({ - api: `${agentStudioBaseUrl(appId)}/agents/${assistantId}/completions?stream=true&compatibilityMode=ai-sdk-5`, + api: `${agentStudioBaseUrl(appId)}/agents/${agentId}/completions?stream=true&compatibilityMode=ai-sdk-5`, headers: { 'x-algolia-application-id': appId, 'x-algolia-api-key': apiKey, @@ -126,7 +125,7 @@ const getAgentStudioTransport = ({ }; export const useAskAi: UseAskAi = ({ - assistantId, + agentId, apiKey, appId, tools = EMPTY_TOOLS, @@ -141,12 +140,12 @@ export const useAskAi: UseAskAi = ({ getAgentStudioTransport({ apiKey, appId, - assistantId, + agentId, searchParameters, userToken: memory?.userToken, indices, }), - [apiKey, appId, assistantId, searchParameters, memory?.userToken, indices] + [apiKey, appId, agentId, searchParameters, memory?.userToken, indices] ); // Store transport in a ref since it is dependent on unstable dependencies: @@ -224,10 +223,10 @@ export const useAskAi: UseAskAi = ({ const sendFeedback = useCallback( async (messageId, { thumbs, tags, notes }): Promise => { - if (!assistantId) return; + if (!agentId) return; const res = await postAgentStudioFeedback({ - agentId: assistantId, + agentId, vote: thumbs, messageId, appId, @@ -244,7 +243,7 @@ export const useAskAi: UseAskAi = ({ { tags, notes } ); }, - [assistantId, appId, apiKey, conversations] + [agentId, appId, apiKey, conversations] ); const onStopStreaming = useCallback(async (): Promise => { diff --git a/packages/docsearch-react/src/useFacetValues.ts b/packages/docsearch-react/src/useFacetValues.ts index 86cb9135..bdfc55f9 100644 --- a/packages/docsearch-react/src/useFacetValues.ts +++ b/packages/docsearch-react/src/useFacetValues.ts @@ -32,7 +32,6 @@ export function useFacetValues({ const facetKeys = stableFacetKeys ? stableFacetKeys.split(',') : []; if (facetKeys.length === 0 || indexes.length === 0) { - setFacetValues({}); return () => { isMounted = false; }; @@ -41,7 +40,6 @@ export function useFacetValues({ searchClient .search({ requests: indexes.map((index) => ({ - ...index.searchParameters, indexName: index.name, query: '', hitsPerPage: 0, diff --git a/packages/docsearch-react/src/useSuggestedQuestions.ts b/packages/docsearch-react/src/useSuggestedQuestions.ts index a90fde3d..93d3b012 100644 --- a/packages/docsearch-react/src/useSuggestedQuestions.ts +++ b/packages/docsearch-react/src/useSuggestedQuestions.ts @@ -10,13 +10,13 @@ import type { } from '.'; type UseSuggestedQuestionsProps = { - assistantId: string | null; + agentId: string | null; searchClient: DocSearchTransformClient; suggestedQuestionsEnabled?: boolean; }; export const useSuggestedQuestions = ({ - assistantId, + agentId, searchClient, suggestedQuestionsEnabled = false, }: UseSuggestedQuestionsProps): SuggestedQuestionHit[] => { @@ -30,7 +30,7 @@ export const useSuggestedQuestions = ({ requests: [ { indexName: SUGGESTED_QUETIONS_INDEX_NAME, - filters: `state:published AND assistantId:${assistantId}`, + filters: `state:published AND assistantId:${agentId}`, hitsPerPage: 3, }, ], @@ -41,10 +41,10 @@ export const useSuggestedQuestions = ({ setSuggestedQuestions(result.hits); }; - if (suggestedQuestionsEnabled && assistantId && assistantId !== '') { + if (suggestedQuestionsEnabled && agentId && agentId !== '') { getSuggestedQuestions(); } - }, [suggestedQuestionsEnabled, assistantId, searchClient]); + }, [suggestedQuestionsEnabled, agentId, searchClient]); return suggestedQuestions; }; diff --git a/packages/docsearch-react/src/utils/__tests__/createDocSearchSources.test.ts b/packages/docsearch-react/src/utils/__tests__/createDocSearchSources.test.ts new file mode 100644 index 00000000..57860388 --- /dev/null +++ b/packages/docsearch-react/src/utils/__tests__/createDocSearchSources.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { DocSearchHit, InternalDocSearchHit } from '../../types'; +import { buildQuerySources } from '../createDocSearchSources'; + +function createHit({ + objectID, + lvl0, + lvl1, + type, +}: { + objectID: string; + lvl0: string; + lvl1: string; + type: DocSearchHit['type']; +}): DocSearchHit { + const hit: DocSearchHit = { + objectID, + content: null, + url: `/${objectID}`, + url_without_anchor: `/${objectID}`, + type, + anchor: null, + hierarchy: { + lvl0, + lvl1, + lvl2: null, + lvl3: null, + lvl4: null, + lvl5: null, + lvl6: null, + }, + _highlightResult: { + content: { value: '', matchLevel: 'none', matchedWords: [] }, + hierarchy: { + lvl0: { value: lvl0, matchLevel: 'none', matchedWords: [] }, + lvl1: { value: lvl1, matchLevel: 'none', matchedWords: [] }, + lvl2: { value: '', matchLevel: 'none', matchedWords: [] }, + lvl3: { value: '', matchLevel: 'none', matchedWords: [] }, + lvl4: { value: '', matchLevel: 'none', matchedWords: [] }, + lvl5: { value: '', matchLevel: 'none', matchedWords: [] }, + lvl6: { value: '', matchLevel: 'none', matchedWords: [] }, + }, + hierarchy_camel: [], + }, + _snippetResult: { + content: { value: '', matchLevel: 'none' }, + hierarchy: { + lvl0: { value: lvl0, matchLevel: 'none', matchedWords: [] }, + lvl1: { value: lvl1, matchLevel: 'none', matchedWords: [] }, + lvl2: { value: '', matchLevel: 'none', matchedWords: [] }, + lvl3: { value: '', matchLevel: 'none', matchedWords: [] }, + lvl4: { value: '', matchLevel: 'none', matchedWords: [] }, + lvl5: { value: '', matchLevel: 'none', matchedWords: [] }, + lvl6: { value: '', matchLevel: 'none', matchedWords: [] }, + }, + hierarchy_camel: [], + }, + }; + + return hit; +} + +describe('buildQuerySources', () => { + it('creates a source per lvl0 group and scopes parents to that group', async () => { + const sources = await buildQuerySources({ + query: 'install', + state: { context: { searchSuggestions: [] } }, + setContext: vi.fn(), + setStatus: vi.fn(), + searchClient: { + search: vi.fn().mockResolvedValue({ + results: [ + { + index: 'docs', + nbHits: 4, + hits: [ + createHit({ + objectID: 'guide-install', + lvl0: 'Guides', + lvl1: 'Installation', + type: 'lvl1', + }), + createHit({ + objectID: 'guide-install-content', + lvl0: 'Guides', + lvl1: 'Installation', + type: 'content', + }), + createHit({ + objectID: 'reference-install', + lvl0: 'Reference', + lvl1: 'Installation', + type: 'lvl1', + }), + createHit({ + objectID: 'reference-install-content', + lvl0: 'Reference', + lvl1: 'Installation', + type: 'content', + }), + ], + }, + ], + }), + } as any, + indexes: [{ name: 'docs' }], + snippetLength: { current: 15 }, + insights: false, + saveRecentSearch: vi.fn(), + onClose: vi.fn(), + facetSelections: { current: {} }, + }); + + expect(sources.map((source) => source.sourceId)).toEqual([ + 'hits_docs_0', + 'hits_docs_1', + ]); + + const [guideSource, referenceSource] = sources; + const guideItems = (await guideSource.getItems( + {} as never + )) as InternalDocSearchHit[]; + const referenceItems = (await referenceSource.getItems( + {} as never + )) as InternalDocSearchHit[]; + + expect(guideItems.map((item) => item.objectID)).toEqual([ + 'guide-install', + 'guide-install-content', + ]); + expect(referenceItems.map((item) => item.objectID)).toEqual([ + 'reference-install', + 'reference-install-content', + ]); + expect(guideItems[1].__docsearch_parent?.objectID).toBe('guide-install'); + expect(referenceItems[1].__docsearch_parent?.objectID).toBe( + 'reference-install' + ); + }); +}); diff --git a/packages/docsearch-react/src/utils/createDocSearchSources.ts b/packages/docsearch-react/src/utils/createDocSearchSources.ts index 784e5f71..428bcb48 100644 --- a/packages/docsearch-react/src/utils/createDocSearchSources.ts +++ b/packages/docsearch-react/src/utils/createDocSearchSources.ts @@ -2,7 +2,11 @@ import type { AutocompleteSource, AutocompleteState, } from '@algolia/autocomplete-core'; -import type { SearchParamsObject, SearchResponse } from 'algoliasearch/lite'; +import type { + FacetFilters, + SearchParamsObject, + SearchResponse, +} from 'algoliasearch/lite'; import type React from 'react'; import type { DocSearchIndex, DocSearchProps } from '../DocSearch'; @@ -34,19 +38,43 @@ export function createFacetFilters( searchParametersFacetFilters: SearchParamsObject['facetFilters'], facetSelections: FacetSelections ): SearchParamsObject['facetFilters'] { - const dynamicFacetFilters = Object.entries(facetSelections) - .filter(([, value]) => value) - .map(([facet, value]) => `${facet}:${value}`); + const selections = Object.entries(facetSelections); + const selectedFacets = new Set(selections.map(([facet]) => facet)); + const dynamicFacetFilters: string[] = []; - if (dynamicFacetFilters.length === 0) { + for (const [facet, selection] of selections) { + if (selection !== '') { + dynamicFacetFilters.push(`${facet}:${selection}`); + } + } + + if (selectedFacets.size === 0) { return searchParametersFacetFilters; } - if (!searchParametersFacetFilters) { - return dynamicFacetFilters; + let configuredFacetFilters: FacetFilters = []; + + if ( + searchParametersFacetFilters && + Array.isArray(searchParametersFacetFilters) + ) { + configuredFacetFilters = searchParametersFacetFilters; + } else if (searchParametersFacetFilters) { + configuredFacetFilters = [searchParametersFacetFilters]; } - return [...searchParametersFacetFilters, ...dynamicFacetFilters]; + const remainingFacetFilters = configuredFacetFilters.filter((facetFilter) => { + if (typeof facetFilter !== 'string') { + return true; + } + + const separatorIndex = facetFilter.indexOf(':'); + const facet = facetFilter.slice(0, separatorIndex); + + return separatorIndex <= 0 || !selectedFacets.has(facet); + }); + + return [...remainingFacetFilters, ...dynamicFacetFilters]; } export function buildNoQuerySources({ @@ -221,10 +249,8 @@ export async function buildQuerySources({ }; } - const items = Object.values(sources).flat(); - - return { - sourceId: `hits_${result.index}`, + return Object.values(sources).map((items, index) => ({ + sourceId: `hits_${result.index}_${index}`, onSelect({ item, event }): void { saveRecentSearch(item); if (!isModifierEvent(event)) { @@ -234,36 +260,34 @@ export async function buildQuerySources({ getItemUrl({ item }): string { return item.url; }, - getItems() { + getItems(): InternalDocSearchHit[] { return Object.values( groupBy(items, (item) => item.hierarchy.lvl1, maxResultsPerGroup) ) .map((groupedHits) => - groupedHits - .map((item) => { - let parent: InternalDocSearchHit | null = null; + groupedHits.map((item) => { + let parent: InternalDocSearchHit | null = null; - const potentialParent = groupedHits.find( - (siblingItem) => - siblingItem.type === 'lvl1' && - siblingItem.hierarchy.lvl1 === item.hierarchy.lvl1 - ) as InternalDocSearchHit | undefined; + const potentialParent = groupedHits.find( + (siblingItem) => + siblingItem.type === 'lvl1' && + siblingItem.hierarchy.lvl1 === item.hierarchy.lvl1 + ) as InternalDocSearchHit | undefined; - if (item.type !== 'lvl1' && potentialParent) { - parent = potentialParent; - } + if (item.type !== 'lvl1' && potentialParent) { + parent = potentialParent; + } - return { - ...item, - __docsearch_parent: parent, - ...insightsParams, - }; - }) - .flat() + return { + ...item, + __docsearch_parent: parent, + ...insightsParams, + }; + }) ) .flat(); }, - }; + })); }); } catch (error) { if ((error as Error).name === 'RetryError') { diff --git a/packages/docsearch-react/src/utils/facets.ts b/packages/docsearch-react/src/utils/facets.ts index d32da173..0561b606 100644 --- a/packages/docsearch-react/src/utils/facets.ts +++ b/packages/docsearch-react/src/utils/facets.ts @@ -1,4 +1,6 @@ -import type { DocSearchFacet } from '../DocSearch'; +import type { DocSearchFacet, DocSearchIndex } from '../DocSearch'; + +import type { FacetSelections } from './createDocSearchSources'; export const MAX_FACETS = 5; @@ -40,3 +42,36 @@ export function getFacetLabel(facet: DocSearchFacet): string { .replace(/[._-]+/g, ' ') .replace(/\b\w/g, (letter) => letter.toUpperCase()); } + +export function deriveDefaultSelectedFacetsFromIndex( + indices: DocSearchIndex[] +): FacetSelections { + const defaultFacets: FacetSelections = {}; + + for (const index of indices) { + const facetFilters = index.searchParameters?.facetFilters; + + for (const facetFilter of Array.isArray(facetFilters) + ? facetFilters + : [facetFilters]) { + if (typeof facetFilter !== 'string') { + continue; + } + + const separatorIndex = facetFilter.indexOf(':'); + + if (separatorIndex <= 0) { + continue; + } + + const key = facetFilter.slice(0, separatorIndex); + const value = facetFilter.slice(separatorIndex + 1); + + if (key && value) { + defaultFacets[key] = value; + } + } + } + + return defaultFacets; +} diff --git a/packages/docsearch-sidepanel/README.md b/packages/docsearch-sidepanel/README.md index 7211a4be..3880324a 100644 --- a/packages/docsearch-sidepanel/README.md +++ b/packages/docsearch-sidepanel/README.md @@ -36,9 +36,8 @@ function App() { ); diff --git a/packages/docsearch-sidepanel/src/Sidepanel.tsx b/packages/docsearch-sidepanel/src/Sidepanel.tsx index fc1ead61..a0fe5d52 100644 --- a/packages/docsearch-sidepanel/src/Sidepanel.tsx +++ b/packages/docsearch-sidepanel/src/Sidepanel.tsx @@ -9,7 +9,11 @@ import type { JSX } from 'react'; import { createPortal } from 'react-dom'; export type SidepanelProps = DocSearchSidepanelProps['panel'] & - Omit & + Omit< + DocSearchSidepanelProps, + 'appId' | 'apiKey' | 'button' | 'panel' | 'theme' + > & + Partial> & SidepanelSearchParameters; export function Sidepanel({ @@ -17,6 +21,8 @@ export function Sidepanel({ ...props }: SidepanelProps): JSX.Element { const { + appId: providerAppId, + apiKey: providerApiKey, docsearchState, setDocsearchState, keyboardShortcuts, @@ -24,6 +30,15 @@ export function Sidepanel({ initialAskAiMessage, } = useDocSearch(); + const appId = props.appId ?? providerAppId; + const apiKey = props.apiKey ?? providerApiKey; + + if (!appId || !apiKey) { + throw new Error( + '`Sidepanel` requires `appId` and `apiKey` props or values configured on the `DocSearch` provider.' + ); + } + const handleOpen = React.useCallback((): void => { setDocsearchState('sidepanel'); }, [setDocsearchState]); @@ -49,12 +64,16 @@ export function Sidepanel({ keyboardShortcuts, initialMessage: initialAskAiMessage, ...props, + appId, + apiKey, }), [ docsearchState, handleOpen, handleClose, props, + appId, + apiKey, keyboardShortcuts, initialAskAiMessage, ] diff --git a/packages/website/docs/composable-api.mdx b/packages/website/docs/composable-api.mdx index ec5c391b..25eaff3f 100644 --- a/packages/website/docs/composable-api.mdx +++ b/packages/website/docs/composable-api.mdx @@ -64,7 +64,7 @@ bun add @docsearch/core@^5.0.0-beta @docsearch/modal@^5.0.0-beta @docsearch/css@ ## Add keyword search -Wrap the button and keyword modal in one `DocSearch` provider. Pass a public search-only API key. Prefer `indices` over the deprecated `indexName` and `searchParameters` props. +Wrap the button and keyword modal in one `DocSearch` provider. Pass a public search-only API key to the provider; its descendants use these credentials by default. ```tsx title="KeywordSearch.tsx" import { DocSearch } from '@docsearch/core'; @@ -85,14 +85,14 @@ export function KeywordSearch({ indexName, }: KeywordSearchProps): JSX.Element { return ( - + - + ); } @@ -102,7 +102,7 @@ The provider opens the modal when a user selects the button, presses Ctrl + ); } ``` -The `askAi` prop also accepts an assistant ID string. Use the object form when you need options such as `indices`, `searchParameters`, `suggestedQuestions`, `promptSuggestions`, `tools`, or `memory`. See the [React package reference](/docs/packages/react/api-reference) for those option types. +The `askAi` prop accepts an assistant ID string or an object with `agentId`. Use the object form when you need options such as `indices`, `searchParameters`, `suggestedQuestions`, `promptSuggestions`, `tools`, or `memory`. Set `appId` and `apiKey` on an individual modal or Sidepanel only when they must override the provider's defaults. ## Understand the shared state @@ -200,7 +198,7 @@ interface ControlledSearchProps { appId: string; apiKey: string; indexName: string; - assistantId: string; + agentId: string; onReady?: () => void; onOpen?: () => void; onClose?: () => void; @@ -231,7 +229,7 @@ export function ControlledSearch(props: ControlledSearchProps): JSX.Element { appId={props.appId} apiKey={props.apiKey} indices={[props.indexName]} - askAi={{ assistantId: props.assistantId }} + askAi={props.agentId} /> ); @@ -339,7 +337,7 @@ interface LazySearchProps { appId: string; apiKey: string; indexName: string; - assistantId: string; + agentId: string; } export function LazySearch(props: LazySearchProps): JSX.Element { @@ -354,7 +352,7 @@ export function LazySearch(props: LazySearchProps): JSX.Element { appId={props.appId} apiKey={props.apiKey} indices={[props.indexName]} - askAi={{ assistantId: props.assistantId }} + askAi={props.agentId} /> ); @@ -401,12 +399,12 @@ Add `@docsearch/css/dist/_askai.css` when you use `DocSearchAskAiModal`. Bundler | API | Required configuration | Provider-managed behavior | | --- | --- | --- | -| `DocSearch` | `children` | State, theme, initial query, shortcuts, focus restoration, lifecycle callbacks, and `DocSearchRef` | +| `DocSearch` | `children` | State, credentials defaults, theme, initial query, shortcuts, focus restoration, lifecycle callbacks, and `DocSearchRef` | | `DocSearchButton` | None | Button ref, theme, shortcuts, and opening keyword search | -| `DocSearchModal` | `appId`, `apiKey`, and at least one `indices` entry or deprecated `indexName` | Open state, close action, initial scroll position, initial query, theme, and shortcuts | +| `DocSearchModal` | At least one `indices` entry or deprecated `indexName`; `appId` and `apiKey` must be set here or on `DocSearch` | Open state, close action, initial scroll position, initial query, theme, and shortcuts | | `DocSearchAskAiModal` | The keyword modal configuration plus `askAi` | Keyword modal behavior, Ask AI state, Ask AI transitions, and hybrid detection | | `useDocSearch` | A parent `DocSearch` provider | Reads the context and throws when used outside the provider | `DocSearchButton` accepts native React button props and `translations`. The connected wrapper doesn't accept `theme` or `keyboardShortcuts`; set those on `DocSearch`. -Both connected modal wrappers accept the corresponding low-level modal options, except the state and lifecycle fields supplied by the provider. Consult the [modal API](/docs/packages/modal/api) before adding options. +Both connected modal wrappers accept the corresponding low-level modal options, except the state and lifecycle fields supplied by the provider. Explicit `appId` and `apiKey` props override their respective provider values. Consult the [modal API](/docs/packages/modal/api) before adding options. diff --git a/packages/website/docs/migrating-from-v4.mdx b/packages/website/docs/migrating-from-v4.mdx index 9217d6d8..a7c4b0af 100644 --- a/packages/website/docs/migrating-from-v4.mdx +++ b/packages/website/docs/migrating-from-v4.mdx @@ -202,7 +202,7 @@ V5 adds `askAi.indices`, `askAi.tools`, `askAi.memory`, and `askAi.promptSuggest ## 4. Move keyword configuration to `indices` -`indexName` and root `searchParameters` still work in v5, but both are deprecated. +`indexName` and root `searchParameters` both have been removed in `v5`. ```diff title="app.js" docsearch({ @@ -223,7 +223,7 @@ V5 adds `askAi.indices`, `askAi.tools`, `askAi.memory`, and `askAi.promptSuggest }); ``` -Use one item per index. DocSearch queries them in array order. If you temporarily pass both `indexName` and `indices`, it queries `indexName` first, so remove the old option to avoid duplicate requests. +Use one item per index. DocSearch queries them in array order. ## 5. Update result customization diff --git a/packages/website/docs/packages/react/api-reference.mdx b/packages/website/docs/packages/react/api-reference.mdx index c325bdb1..cd1b4813 100644 --- a/packages/website/docs/packages/react/api-reference.mdx +++ b/packages/website/docs/packages/react/api-reference.mdx @@ -43,7 +43,7 @@ Public API key with search permission. > `type: Array` | **optional** -Indices to search in display order. Provide `indices` or the deprecated [`indexName`](#indexname). +Indices to search in display order. ```tsx title="Search.tsx" ``` -### `indexName` - -> `type: string` | **optional** | **deprecated** - -Single index to search. Use [`indices`](#indices) instead. If you pass both options, DocSearch queries `indexName` first. - ### `facets` > `type: DocSearchFacet[]` | **optional** @@ -87,12 +81,6 @@ Theme written to `document.documentElement.dataset.theme`. By default, DocSearch Search input placeholder. The active experience supplies the default. -### `searchParameters` - -> `type: SearchParamsObject` | **optional** | **deprecated** - -Search parameters for `indexName`. Set `searchParameters` on an [`indices`](#indices) item instead. - ### `maxResultsPerGroup` > `type: number` | **optional** @@ -250,11 +238,11 @@ Primitive values render as text and arrays of primitives render as a comma-separ Agent Studio assistant ID or configuration. This prop is required by `DocSearchAI`. -#### `assistantId` +#### `agentId` > `type: string` | **required** -Agent Studio assistant ID. +Agent Studio agent ID. #### `appId` @@ -268,12 +256,6 @@ Application ID for Agent Studio. Defaults to the root `appId`. API key for Agent Studio. Defaults to the root `apiKey`. -#### `indexName` - -> `type: string` | **optional** - -Index for Agent Studio. Defaults to the first normalized keyword index. - #### `suggestedQuestions` > `type: boolean` | **optional** @@ -289,7 +271,7 @@ Search parameters keyed by index name. Each value supports `filters`, `attribute ```tsx title="Search.tsx" =16.8.0 <20.0.0`. - `search-insights` supports versions `>=1 <3` and is optional. - The browser build targets ES2017. -- `indexName` and root `searchParameters` remain supported but are deprecated. Move to `indices`. - `UseDocSearchKeyboardEventsProps.onInput` and `searchButtonRef` remain accepted for compatibility but are deprecated. - `DocSearch` is keyword-only in v5. Use `DocSearchAI` for Ask AI. diff --git a/packages/website/docusaurus.config.mjs b/packages/website/docusaurus.config.mjs index 8c70af8f..836dfd49 100644 --- a/packages/website/docusaurus.config.mjs +++ b/packages/website/docusaurus.config.mjs @@ -82,7 +82,7 @@ export default { apiKey: '24b09689d5b4223813d9b8e48563c8f6', indices: [{ name: 'docsearch' }], askAi: { - assistantId: 'ccdec697-e3fe-465b-a1c3-657e7bf18aef', + agentId: 'ccdec697-e3fe-465b-a1c3-657e7bf18aef', }, sidePanel: true, contextualSearch: true, diff --git a/packages/website/src/components/demo/DemoApp.jsx b/packages/website/src/components/demo/DemoApp.jsx index f3edaf84..30819844 100644 --- a/packages/website/src/components/demo/DemoApp.jsx +++ b/packages/website/src/components/demo/DemoApp.jsx @@ -80,17 +80,26 @@ export default function DemoApp() { HTMLElement.prototype.focus = function focusNoScroll(options) { if (!userEngaged) return undefined; - return nativeFocus.call(this, { preventScroll: true, ...(options ?? {}) }); + return nativeFocus.call(this, { + preventScroll: true, + ...(options ?? {}), + }); }; Element.prototype.scrollIntoView = function containedScrollIntoView(arg) { - const behavior = typeof arg === 'object' && arg?.behavior ? arg.behavior : 'auto'; + const behavior = + typeof arg === 'object' && arg?.behavior ? arg.behavior : 'auto'; let ancestor = this.parentElement; while (ancestor) { const overflowY = getComputedStyle(ancestor).overflowY; - if ((overflowY === 'auto' || overflowY === 'scroll') && ancestor.scrollHeight > ancestor.clientHeight) { + if ( + (overflowY === 'auto' || overflowY === 'scroll') && + ancestor.scrollHeight > ancestor.clientHeight + ) { const top = - this.getBoundingClientRect().top - ancestor.getBoundingClientRect().top + ancestor.scrollTop; + this.getBoundingClientRect().top - + ancestor.getBoundingClientRect().top + + ancestor.scrollTop; ancestor.scrollTo({ top, behavior }); return; } @@ -164,10 +173,9 @@ export default function DemoApp() { @@ -186,7 +194,7 @@ export default function DemoApp() { indices={[INDEX_NAME]} appId={APP_ID} apiKey={API_KEY} - askAi={{ assistantId: ASSISTANT_ID }} + askAi={ASSISTANT_ID} navigator={navigator} /> diff --git a/packages/website/src/components/demo/askai-fixture.js b/packages/website/src/components/demo/askai-fixture.js index a2f0bf31..0386c10a 100644 --- a/packages/website/src/components/demo/askai-fixture.js +++ b/packages/website/src/components/demo/askai-fixture.js @@ -31,7 +31,7 @@ npm install @docsearch/react@4 @docsearch/css@4 import { DocSearch } from '@docsearch/react'; import '@docsearch/css'; - + \`\`\` ### Docusaurus (recommended) @@ -47,7 +47,7 @@ themeConfig: { docsearch: { appId: 'YOUR_APP_ID', apiKey: 'YOUR_SEARCH_KEY', - indexName: 'YOUR_INDEX', + indices: ['YOUR_INDEX'], }, }, \`\`\`