1
0
Fork 0

feat(v5): UI and DX improvements (#2949)

* feat: Rename assistantId to agentId

* feat: Allow reading default facet values from index searchParameters

* feat: Remove indexName prop from Sidepanel, cleanup documentation pages

* feat: Move appId and apiKey up into @docsearch/core

* feat: Add back nested grouping of search results

* add changeset

* revert changes to example demo

* fix: e2e tests
This commit is contained in:
Paul Jankowski 2026-08-04 09:43:08 -04:00 committed by GitHub
parent 5eac1fd6bb
commit 4f6b5b1b88
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
52 changed files with 781 additions and 283 deletions

View file

@ -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

View file

@ -117,7 +117,6 @@
"new-cap": ["error"], "new-cap": ["error"],
"no-array-constructor": ["error"], "no-array-constructor": ["error"],
"no-bitwise": ["error"], "no-bitwise": ["error"],
"no-continue": ["error"],
"no-lonely-if": ["error"], "no-lonely-if": ["error"],
"no-nested-ternary": ["error"], "no-nested-ternary": ["error"],
"no-unneeded-ternary": ["error"], "no-unneeded-ternary": ["error"],

View file

@ -23,7 +23,7 @@ const minimalDocSearchConfig = {
} satisfies DocSearchInput; } satisfies DocSearchInput;
const minimalAskAiConfig = { const minimalAskAiConfig = {
assistantId: 'my-assistant-id', agentId: 'my-assistant-id',
} satisfies NonNullable<DocSearchInput>['askAi']; } satisfies NonNullable<DocSearchInput>['askAi'];
const askAiConfigWithIndices = { const askAiConfigWithIndices = {

View file

@ -47,7 +47,7 @@ declare module '@docsearch/docusaurus-adapter' {
}; };
export type AskAiConfig = { export type AskAiConfig = {
assistantId: DocSearchAskAi['assistantId']; agentId: DocSearchAskAi['agentId'];
suggestedQuestions?: DocSearchAskAi['suggestedQuestions']; suggestedQuestions?: DocSearchAskAi['suggestedQuestions'];
searchParameters?: AgentStudioSearchParameters; searchParameters?: AgentStudioSearchParameters;
indices?: AgentStudioIndices[]; indices?: AgentStudioIndices[];

View file

@ -79,6 +79,8 @@ type AdapterDocSearchProps = Omit<
| 'onAskAiToggle' | 'onAskAiToggle'
| 'onClose' | 'onClose'
> & { > & {
appId: DocSearchProps['appId'];
apiKey: DocSearchProps['apiKey'];
askAi?: AskAiOptions; askAi?: AskAiOptions;
contextualSearch?: boolean; contextualSearch?: boolean;
externalUrlRegex?: string; externalUrlRegex?: string;
@ -422,10 +424,9 @@ function DocSearch({
{...panelOptions} {...panelOptions}
variant={panelOptions.variant ?? 'inline'} variant={panelOptions.variant ?? 'inline'}
pushSelector={panelOptions.pushSelector ?? '#__docusaurus'} pushSelector={panelOptions.pushSelector ?? '#__docusaurus'}
assistantId={sidePanelAskAi.assistantId} agentId={sidePanelAskAi.agentId}
apiKey={sidePanelAskAi.apiKey} apiKey={sidePanelAskAi.apiKey}
appId={sidePanelAskAi.appId} appId={sidePanelAskAi.appId}
indexName={sidePanelAskAi.indexName}
searchParameters={sidePanelAskAi.searchParameters} searchParameters={sidePanelAskAi.searchParameters}
indices={panelOptions.indices ?? sidePanelAskAi.indices} indices={panelOptions.indices ?? sidePanelAskAi.indices}
suggestedQuestions={ suggestedQuestions={
@ -454,6 +455,8 @@ export default function SearchBar(
return ( return (
<DocSearchProvider <DocSearchProvider
appId={docSearchProps.appId}
apiKey={docSearchProps.apiKey}
initialQuery={docSearchProps.initialQuery} initialQuery={docSearchProps.initialQuery}
keyboardShortcuts={docSearchProps.keyboardShortcuts} keyboardShortcuts={docSearchProps.keyboardShortcuts}
> >

View file

@ -131,7 +131,7 @@ const AskAiPromptSuggestionsSchema = Joi.object({
}).unknown(false); }).unknown(false);
const AskAiSchema = Joi.object({ const AskAiSchema = Joi.object({
assistantId: Joi.string().required(), agentId: Joi.string().required(),
suggestedQuestions: Joi.boolean().optional(), suggestedQuestions: Joi.boolean().optional(),
searchParameters: Joi.object() searchParameters: Joi.object()
.pattern(Joi.string(), SearchParametersSchema) .pattern(Joi.string(), SearchParametersSchema)
@ -261,7 +261,7 @@ function assertNoRemovedKeys(themeConfig: ThemeConfig): void {
const askAi = docsearchRecord.askAi; const askAi = docsearchRecord.askAi;
if (typeof askAi === 'string') { if (typeof askAi === 'string') {
throw new Error( throw new Error(
'`themeConfig.docsearch.askAi` must be an object with `assistantId`.' '`themeConfig.docsearch.askAi` must be an object with `agentId`.'
); );
} }

View file

@ -30,7 +30,7 @@ test.describe('a11y > Modal', () => {
await expect(docSearch.hits).toBeVisible(); await expect(docSearch.hits).toBeVisible();
const scanResults = await axe() const scanResults = await axe()
.include('#docsearch-hits_docsearch-list') .include('#docsearch-hits_docsearch_0-list')
.analyze(); .analyze();
await testInfo.attach('a11y-scan-results-modal-search-results', { await testInfo.attach('a11y-scan-results-modal-search-results', {

View file

@ -24,7 +24,7 @@ export class DocSearchPage {
this.hits = page.locator('.DocSearch-Hits').first(); this.hits = page.locator('.DocSearch-Hits').first();
this.clearButton = page.locator('.DocSearch-Clear'); this.clearButton = page.locator('.DocSearch-Clear');
this.firstHit = page this.firstHit = page
.locator('#docsearch-hits_docsearch-list .DocSearch-Hit a') .locator('#docsearch-hits_docsearch_0-list .DocSearch-Hit a')
.first(); .first();
} }

View file

@ -113,7 +113,7 @@ test.describe('Search', () => {
await docSearch.typeQueryMatching(); await docSearch.typeQueryMatching();
await expect(docSearch.hits).toBeVisible(); await expect(docSearch.hits).toBeVisible();
const firstHitOption = page const firstHitOption = page
.locator('#docsearch-hits_docsearch-list') .locator('#docsearch-hits_docsearch_0-list')
.getByRole('option') .getByRole('option')
.first(); .first();
const firstHitOptionId = await firstHitOption.getAttribute('id'); const firstHitOptionId = await firstHitOption.getAttribute('id');

View file

@ -36,10 +36,9 @@ let sidepanelInstance: SidepanelInstance | undefined = undefined;
sidepanelInstance = sidepanel({ sidepanelInstance = sidepanel({
container: '#docsearch-sidepanel', container: '#docsearch-sidepanel',
indexName: 'docsearch',
appId: 'PMZUYBQDAK', appId: 'PMZUYBQDAK',
apiKey: '24b09689d5b4223813d9b8e48563c8f6', apiKey: '24b09689d5b4223813d9b8e48563c8f6',
assistantId: 'ccdec697-e3fe-465b-a1c3-657e7bf18aef', agentId: 'ccdec697-e3fe-465b-a1c3-657e7bf18aef',
onReady: () => { onReady: () => {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.log('[demo-js] sidepanel onReady()'); console.log('[demo-js] sidepanel onReady()');
@ -73,9 +72,7 @@ docsearchInstance = docsearch({
indices: ['docsearch'], indices: ['docsearch'],
appId: 'PMZUYBQDAK', appId: 'PMZUYBQDAK',
apiKey: '24b09689d5b4223813d9b8e48563c8f6', apiKey: '24b09689d5b4223813d9b8e48563c8f6',
askAi: { askAi: 'ccdec697-e3fe-465b-a1c3-657e7bf18aef',
assistantId: 'ccdec697-e3fe-465b-a1c3-657e7bf18aef',
},
interceptAskAiEvent: (initialMessage) => { interceptAskAiEvent: (initialMessage) => {
docsearchInstance?.close(); docsearchInstance?.close();
sidepanelInstance.open(initialMessage); sidepanelInstance.open(initialMessage);

View file

@ -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';

View file

@ -3,6 +3,7 @@ import { DocSearchAI, type ToolCalls } from '@docsearch/react';
import type { JSX } from 'react'; import type { JSX } from 'react';
import type { DemoTheme } from '../App'; import type { DemoTheme } from '../App';
import { AGENT_ID, API_KEY, APP_ID, SEARCH_INDEX_NAME } from '../constants';
const customTools: ToolCalls = { const customTools: ToolCalls = {
printConsoleMessage: { printConsoleMessage: {
@ -29,11 +30,11 @@ export default function BasicAskAI({
}): JSX.Element { }): JSX.Element {
return ( return (
<DocSearchAI <DocSearchAI
indices={['docsearch']} indices={[SEARCH_INDEX_NAME]}
appId="PMZUYBQDAK" appId={APP_ID}
apiKey="24b09689d5b4223813d9b8e48563c8f6" apiKey={API_KEY}
askAi={{ askAi={{
assistantId: 'ccdec697-e3fe-465b-a1c3-657e7bf18aef', agentId: AGENT_ID,
suggestedQuestions: true, suggestedQuestions: true,
tools: customTools, tools: customTools,
promptSuggestions: { promptSuggestions: {

View file

@ -3,13 +3,14 @@ import { DocSearch } from '@docsearch/react';
import type { JSX } from 'react'; import type { JSX } from 'react';
import type { DemoTheme } from '../App'; import type { DemoTheme } from '../App';
import { API_KEY, APP_ID, SEARCH_INDEX_NAME } from '../constants';
export default function Basic({ theme }: { theme: DemoTheme }): JSX.Element { export default function Basic({ theme }: { theme: DemoTheme }): JSX.Element {
return ( return (
<DocSearch <DocSearch
indices={['docsearch']} indices={[SEARCH_INDEX_NAME]}
appId="PMZUYBQDAK" appId={APP_ID}
apiKey="24b09689d5b4223813d9b8e48563c8f6" apiKey={API_KEY}
translations={{ button: { buttonText: 'Keyword search' } }} translations={{ button: { buttonText: 'Keyword search' } }}
insights={true} insights={true}
theme={theme} theme={theme}

View file

@ -4,6 +4,7 @@ import { DocSearchButton, DocSearchAskAiModal } from '@docsearch/modal';
import { type JSX } from 'react'; import { type JSX } from 'react';
import type { DemoTheme } from '../App'; import type { DemoTheme } from '../App';
import { AGENT_ID, API_KEY, APP_ID, SEARCH_INDEX_NAME } from '../constants';
export default function Composable({ export default function Composable({
theme, theme,
@ -11,16 +12,9 @@ export default function Composable({
theme: DemoTheme; theme: DemoTheme;
}): JSX.Element { }): JSX.Element {
return ( return (
<DocSearch theme={theme}> <DocSearch appId={APP_ID} apiKey={API_KEY} theme={theme}>
<DocSearchButton translations={{ buttonText: 'Composable API' }} /> <DocSearchButton translations={{ buttonText: 'Composable API' }} />
<DocSearchAskAiModal <DocSearchAskAiModal askAi={AGENT_ID} indices={[SEARCH_INDEX_NAME]} />
indices={['docsearch']}
appId="PMZUYBQDAK"
apiKey="24b09689d5b4223813d9b8e48563c8f6"
askAi={{
assistantId: 'ccdec697-e3fe-465b-a1c3-657e7bf18aef',
}}
/>
</DocSearch> </DocSearch>
); );
} }

View file

@ -3,6 +3,7 @@ import { DocSearch } from '@docsearch/react';
import type { JSX } from 'react'; import type { JSX } from 'react';
import type { DemoTheme } from '../App'; import type { DemoTheme } from '../App';
import { API_KEY, APP_ID, SEARCH_INDEX_NAME } from '../constants';
export default function DefaultExperience({ export default function DefaultExperience({
theme, theme,
@ -11,9 +12,9 @@ export default function DefaultExperience({
}): JSX.Element { }): JSX.Element {
return ( return (
<DocSearch <DocSearch
indices={['docsearch']} indices={[SEARCH_INDEX_NAME]}
appId="PMZUYBQDAK" appId={APP_ID}
apiKey="24b09689d5b4223813d9b8e48563c8f6" apiKey={API_KEY}
theme={theme} theme={theme}
/> />
); );

View file

@ -6,6 +6,7 @@ import { useCallback, useRef, useState, type JSX } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import type { DemoTheme } from '../App'; import type { DemoTheme } from '../App';
import { AGENT_ID, API_KEY, APP_ID, SEARCH_INDEX_NAME } from '../constants';
let DocSearchModal: typeof DocSearchAskAiModalType | null = null; let DocSearchModal: typeof DocSearchAskAiModalType | null = null;
@ -94,12 +95,10 @@ function DocSearch({ theme }: { theme: DemoTheme }): JSX.Element {
searchContainer.current && searchContainer.current &&
createPortal( createPortal(
<DocSearchModal <DocSearchModal
indices={['docsearch']} indices={[SEARCH_INDEX_NAME]}
appId="PMZUYBQDAK" appId={APP_ID}
apiKey="24b09689d5b4223813d9b8e48563c8f6" apiKey={API_KEY}
askAi={{ askAi={AGENT_ID}
assistantId: 'ccdec697-e3fe-465b-a1c3-657e7bf18aef',
}}
initialScrollY={window.scrollY} initialScrollY={window.scrollY}
initialQuery={initialQuery} initialQuery={initialQuery}
isAskAiActive={isAskAiActive} isAskAiActive={isAskAiActive}

View file

@ -5,6 +5,7 @@ import { Sidepanel, SidepanelButton } from '@docsearch/sidepanel';
import type { JSX } from 'react'; import type { JSX } from 'react';
import type { DemoTheme } from '../App'; import type { DemoTheme } from '../App';
import { AGENT_ID, API_KEY, APP_ID } from '../constants';
export default function BasicHybrid({ export default function BasicHybrid({
theme, theme,
@ -12,24 +13,12 @@ export default function BasicHybrid({
theme: DemoTheme; theme: DemoTheme;
}): JSX.Element { }): JSX.Element {
return ( return (
<DocSearch theme={theme}> <DocSearch appId={APP_ID} apiKey={API_KEY} theme={theme}>
<DocSearchButton /> <DocSearchButton />
<DocSearchAskAiModal <DocSearchAskAiModal askAi={AGENT_ID} indices={['docsearch']} />
indices={['docsearch']}
appId="PMZUYBQDAK"
apiKey="24b09689d5b4223813d9b8e48563c8f6"
askAi={{
assistantId: 'ccdec697-e3fe-465b-a1c3-657e7bf18aef',
}}
/>
<SidepanelButton /> <SidepanelButton />
<Sidepanel <Sidepanel agentId={AGENT_ID} />
indexName="docsearch-markdown"
appId="PMZUYBQDAK"
apiKey="24b09689d5b4223813d9b8e48563c8f6"
assistantId="ccdec697-e3fe-465b-a1c3-657e7bf18aef"
/>
</DocSearch> </DocSearch>
); );
} }

View file

@ -3,6 +3,7 @@ import { DocSearch } from '@docsearch/react';
import type { JSX } from 'react'; import type { JSX } from 'react';
import type { DemoTheme } from '../App'; import type { DemoTheme } from '../App';
import { API_KEY, APP_ID, SEARCH_INDEX_NAME } from '../constants';
export default function MultiIndex({ export default function MultiIndex({
theme, theme,
@ -13,7 +14,7 @@ export default function MultiIndex({
<DocSearch <DocSearch
indices={[ indices={[
{ {
name: 'docsearch', name: SEARCH_INDEX_NAME,
}, },
{ {
name: 'tailwindcss', name: 'tailwindcss',
@ -22,8 +23,8 @@ export default function MultiIndex({
name: 'kubernetes', name: 'kubernetes',
}, },
]} ]}
appId="PMZUYBQDAK" appId={APP_ID}
apiKey="24b09689d5b4223813d9b8e48563c8f6" apiKey={API_KEY}
translations={{ button: { buttonText: 'Multi index search' } }} translations={{ button: { buttonText: 'Multi index search' } }}
insights={true} insights={true}
theme={theme} theme={theme}

View file

@ -4,6 +4,7 @@ import { SidepanelButton, Sidepanel } from '@docsearch/sidepanel';
import type { JSX } from 'react'; import type { JSX } from 'react';
import type { DemoTheme } from '../App'; import type { DemoTheme } from '../App';
import { AGENT_ID, API_KEY, APP_ID } from '../constants';
export default function SidepanelExample({ export default function SidepanelExample({
theme, theme,
@ -11,13 +12,10 @@ export default function SidepanelExample({
theme: DemoTheme; theme: DemoTheme;
}): JSX.Element { }): JSX.Element {
return ( return (
<DocSearch theme={theme}> <DocSearch appId={APP_ID} apiKey={API_KEY} theme={theme}>
<SidepanelButton variant="inline" /> <SidepanelButton variant="inline" />
<Sidepanel <Sidepanel
indexName="docsearch" agentId={AGENT_ID}
appId="PMZUYBQDAK"
apiKey="24b09689d5b4223813d9b8e48563c8f6"
assistantId="ccdec697-e3fe-465b-a1c3-657e7bf18aef"
variant="floating" variant="floating"
tools={{ tools={{
printConsoleMessage: { printConsoleMessage: {

View file

@ -40,9 +40,7 @@ export default function WTransformItems({
]} ]}
appId="PMZUYBQDAK" appId="PMZUYBQDAK"
apiKey="24b09689d5b4223813d9b8e48563c8f6" apiKey="24b09689d5b4223813d9b8e48563c8f6"
askAi={{ askAi="ccdec697-e3fe-465b-a1c3-657e7bf18aef"
assistantId: 'ccdec697-e3fe-465b-a1c3-657e7bf18aef',
}}
insights={true} insights={true}
transformItems={(items) => { transformItems={(items) => {
return items.map((item: any) => ({ return items.map((item: any) => ({

View file

@ -48,6 +48,10 @@ export interface DocSearchRef {
} }
export interface DocSearchContext { 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; docsearchState: DocSearchState;
setDocsearchState: (newState: DocSearchState) => void; setDocsearchState: (newState: DocSearchState) => void;
searchButtonRef: React.RefObject<HTMLButtonElement | null>; searchButtonRef: React.RefObject<HTMLButtonElement | null>;
@ -79,6 +83,10 @@ export interface DocSearchCallbacks {
export interface DocSearchProps extends DocSearchCallbacks { export interface DocSearchProps extends DocSearchCallbacks {
children: Array<JSX.Element | null> | JSX.Element | React.ReactNode | null; children: Array<JSX.Element | null> | 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; theme?: DocSearchTheme;
initialQuery?: string; initialQuery?: string;
keyboardShortcuts?: KeyboardShortcuts; keyboardShortcuts?: KeyboardShortcuts;
@ -90,6 +98,8 @@ Context.displayName = 'DocSearchContext';
function DocSearchInner( function DocSearchInner(
{ {
children, children,
appId,
apiKey,
theme, theme,
onReady, onReady,
onOpen, onOpen,
@ -267,6 +277,8 @@ function DocSearchInner(
const value: DocSearchContext = React.useMemo( const value: DocSearchContext = React.useMemo(
() => ({ () => ({
appId,
apiKey,
docsearchState, docsearchState,
setDocsearchState, setDocsearchState,
searchButtonRef, searchButtonRef,
@ -282,6 +294,8 @@ function DocSearchInner(
isHybridModeSupported, isHybridModeSupported,
}), }),
[ [
appId,
apiKey,
docsearchState, docsearchState,
searchButtonRef, searchButtonRef,
initialQuery, initialQuery,

View file

@ -67,6 +67,18 @@ describe('@docsearch/core', () => {
expect(screen.getByText('State: ready')).toBeInTheDocument(); 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 () => { it('updates state from children', async () => {
const Comp = (): JSX.Element => { const Comp = (): JSX.Element => {
const { docsearchState, openModal } = useDocSearch(); const { docsearchState, openModal } = useDocSearch();

View file

@ -7,6 +7,8 @@ import { createPortal } from 'react-dom';
export type DocSearchAskAiModalProps = Omit< export type DocSearchAskAiModalProps = Omit<
ReactDocSearchAskAiModalProps, ReactDocSearchAskAiModalProps,
| 'appId'
| 'apiKey'
| 'initialScrollY' | 'initialScrollY'
| 'isAskAiActive' | 'isAskAiActive'
| 'isHybridModeSupported' | 'isHybridModeSupported'
@ -14,12 +16,15 @@ export type DocSearchAskAiModalProps = Omit<
| 'onAskAiToggle' | 'onAskAiToggle'
| 'onClose' | 'onClose'
| 'theme' | 'theme'
>; > &
Partial<Pick<ReactDocSearchAskAiModalProps, 'appId' | 'apiKey'>>;
export function DocSearchAskAiModal( export function DocSearchAskAiModal(
props: DocSearchAskAiModalProps props: DocSearchAskAiModalProps
): JSX.Element | null { ): JSX.Element | null {
const { const {
appId: providerAppId,
apiKey: providerApiKey,
isModalActive, isModalActive,
onAskAiToggle, onAskAiToggle,
closeModal, closeModal,
@ -29,6 +34,15 @@ export function DocSearchAskAiModal(
isHybridModeSupported, isHybridModeSupported,
} = useDocSearch(); } = 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( const containerElement = React.useMemo(
() => props.portalContainer ?? document.body, () => props.portalContainer ?? document.body,
[props.portalContainer] [props.portalContainer]
@ -43,6 +57,8 @@ export function DocSearchAskAiModal(
const modalProps: ReactDocSearchAskAiModalProps = React.useMemo( const modalProps: ReactDocSearchAskAiModalProps = React.useMemo(
() => ({ () => ({
...props, ...props,
appId,
apiKey,
isAskAiActive, isAskAiActive,
initialQuery: props.initialQuery ?? initialQuery, initialQuery: props.initialQuery ?? initialQuery,
initialScrollY: initialScroll, initialScrollY: initialScroll,
@ -52,6 +68,8 @@ export function DocSearchAskAiModal(
}), }),
[ [
props, props,
appId,
apiKey,
isAskAiActive, isAskAiActive,
initialQuery, initialQuery,
initialScroll, initialScroll,

View file

@ -7,12 +7,33 @@ import { createPortal } from 'react-dom';
export type DocSearchModalProps = Omit< export type DocSearchModalProps = Omit<
ReactDocSearchModalProps, ReactDocSearchModalProps,
'initialScrollY' | 'keyboardShortcuts' | 'onClose' | 'theme' | 'appId'
>; | 'apiKey'
| 'initialScrollY'
| 'keyboardShortcuts'
| 'onClose'
| 'theme'
> &
Partial<Pick<ReactDocSearchModalProps, 'appId' | 'apiKey'>>;
export function DocSearchModal(props: DocSearchModalProps): JSX.Element | null { export function DocSearchModal(props: DocSearchModalProps): JSX.Element | null {
const { isModalActive, closeModal, initialQuery, registerView } = const {
useDocSearch(); 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( const containerElement = React.useMemo(
() => props.portalContainer ?? document.body, () => props.portalContainer ?? document.body,
@ -28,11 +49,13 @@ export function DocSearchModal(props: DocSearchModalProps): JSX.Element | null {
const modalProps: ReactDocSearchModalProps = React.useMemo( const modalProps: ReactDocSearchModalProps = React.useMemo(
() => ({ () => ({
...props, ...props,
appId,
apiKey,
initialQuery: props.initialQuery ?? initialQuery, initialQuery: props.initialQuery ?? initialQuery,
initialScrollY: initialScroll, initialScrollY: initialScroll,
onClose: closeModal, onClose: closeModal,
}), }),
[props, initialQuery, initialScroll, closeModal] [props, appId, apiKey, initialQuery, initialScroll, closeModal]
); );
return isModalActive return isModalActive

View file

@ -47,6 +47,11 @@ const DEFAULT_PROPS: DocSearchModalProps = {
}, },
}; };
const PROVIDER_DEFAULT_PROPS: DocSearchModalProps = {
indices: [INDEX_NAME],
transformSearchClient: DEFAULT_PROPS.transformSearchClient,
};
const renderComponent = ( const renderComponent = (
props: DocSearchModalProps = DEFAULT_PROPS, props: DocSearchModalProps = DEFAULT_PROPS,
docsearchProps: Omit<DocSearchProps, 'children'> = {} docsearchProps: Omit<DocSearchProps, 'children'> = {}
@ -67,6 +72,22 @@ describe('@docsearch/modal', () => {
renderComponent(); 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', () => { it('opens modal on keyboard shortcut', () => {
renderComponent(); renderComponent();

View file

@ -157,11 +157,11 @@ export interface DocSearchProps {
} }
function DocSearchComponent( function DocSearchComponent(
props: DocSearchProps, { appId, apiKey, ...props }: DocSearchProps,
ref: React.ForwardedRef<DocSearchRef> ref: React.ForwardedRef<DocSearchRef>
): JSX.Element { ): JSX.Element {
return ( return (
<DocSearchProvider {...props} ref={ref}> <DocSearchProvider {...props} appId={appId} apiKey={apiKey} ref={ref}>
<DocSearchInner {...props} /> <DocSearchInner {...props} />
</DocSearchProvider> </DocSearchProvider>
); );
@ -169,7 +169,9 @@ function DocSearchComponent(
export const DocSearch = React.forwardRef(DocSearchComponent); export const DocSearch = React.forwardRef(DocSearchComponent);
export function DocSearchInner(props: DocSearchProps): JSX.Element { export function DocSearchInner(
props: Omit<DocSearchProps, 'appId' | 'apiKey'>
): JSX.Element {
const { const {
searchButtonRef, searchButtonRef,
keyboardShortcuts, keyboardShortcuts,
@ -177,8 +179,14 @@ export function DocSearchInner(props: DocSearchProps): JSX.Element {
initialQuery, initialQuery,
openModal, openModal,
closeModal, closeModal,
appId,
apiKey,
} = useDocSearch(); } = useDocSearch();
if (!appId || !apiKey) {
throw new Error('`DocSearch` requires `appId` and `apiKey` props.');
}
return ( return (
<> <>
<DocSearchButton <DocSearchButton
@ -191,6 +199,8 @@ export function DocSearchInner(props: DocSearchProps): JSX.Element {
createPortal( createPortal(
<DocSearchModal <DocSearchModal
{...props} {...props}
appId={appId}
apiKey={apiKey}
initialScrollY={window.scrollY} initialScrollY={window.scrollY}
initialQuery={initialQuery} initialQuery={initialQuery}
translations={props?.translations?.modal} translations={props?.translations?.modal}

View file

@ -154,11 +154,6 @@ export interface PromptSuggestions {
} }
export interface DocSearchAskAi { export interface DocSearchAskAi {
/**
* The index name to use for the Ask AI feature. Your assistant will search
* for relevant documents. If not provided, the root index name will be used.
*/
indexName?: string;
/** /**
* The API key to use for the ask AI feature. Your assistant will use this API * The API key to use for the ask AI feature. Your assistant will use this API
* key to search the index. If not provided, the API key will be used. * key to search the index. If not provided, the API key will be used.
@ -169,8 +164,8 @@ export interface DocSearchAskAi {
* ID to search the index. If not provided, the app ID will be used. * ID to search the index. If not provided, the app ID will be used.
*/ */
appId?: string; appId?: string;
/** The assistant ID to use for the ask AI feature. */ /** The Agent Studio Agent ID to use for the Ask AI feature. */
assistantId: string; agentId: string;
/** /**
* Enables displaying suggested questions on Ask AI's new conversation screen. * Enables displaying suggested questions on Ask AI's new conversation screen.
* *
@ -236,11 +231,16 @@ export interface DocSearchAIProps extends DocSearchProps {
} }
function DocSearchAIComponent( function DocSearchAIComponent(
props: DocSearchAIProps, { appId, apiKey, ...props }: DocSearchAIProps,
ref: React.ForwardedRef<DocSearchRef> ref: React.ForwardedRef<DocSearchRef>
): JSX.Element { ): JSX.Element {
return ( return (
<DocSearchProvider {...props} ref={ref}> <DocSearchProvider
{...props}
appId={appId}
apiKey={apiKey}
ref={ref}
>
<DocSearchAIInner {...props} /> <DocSearchAIInner {...props} />
</DocSearchProvider> </DocSearchProvider>
); );
@ -248,7 +248,9 @@ function DocSearchAIComponent(
export const DocSearchAI = React.forwardRef(DocSearchAIComponent); export const DocSearchAI = React.forwardRef(DocSearchAIComponent);
export function DocSearchAIInner(props: DocSearchAIProps): JSX.Element { export function DocSearchAIInner(
props: Omit<DocSearchAIProps, 'appId' | 'apiKey'>
): JSX.Element {
const { const {
searchButtonRef, searchButtonRef,
keyboardShortcuts, keyboardShortcuts,
@ -259,8 +261,14 @@ export function DocSearchAIInner(props: DocSearchAIProps): JSX.Element {
openModal, openModal,
closeModal, closeModal,
isHybridModeSupported, isHybridModeSupported,
appId,
apiKey,
} = useDocSearch(); } = useDocSearch();
if (!appId || !apiKey) {
throw new Error('`DocSearchAI` requires `appId` and `apiKey` props.');
}
return ( return (
<> <>
<DocSearchButton <DocSearchButton
@ -273,6 +281,8 @@ export function DocSearchAIInner(props: DocSearchAIProps): JSX.Element {
createPortal( createPortal(
<DocSearchAskAiModal <DocSearchAskAiModal
{...props} {...props}
appId={appId}
apiKey={apiKey}
initialScrollY={window.scrollY} initialScrollY={window.scrollY}
initialQuery={initialQuery} initialQuery={initialQuery}
translations={props?.translations?.modal} translations={props?.translations?.modal}

View file

@ -157,12 +157,12 @@ export function DocSearchAskAiModal({
const askAiConfig = typeof askAi === 'object' ? askAi : null; const askAiConfig = typeof askAi === 'object' ? askAi : null;
const askAiConfigurationId = askAiConfig const askAiConfigurationId = askAiConfig
? askAiConfig.assistantId ? askAiConfig.agentId
: (askAi as string); : (askAi as string);
const askAiSearchParameters = askAiConfig?.searchParameters; const askAiSearchParameters = askAiConfig?.searchParameters;
const [askAiState, setAskAiState] = React.useState<AskAiState>('initial'); const [askAiState, setAskAiState] = React.useState<AskAiState>('initial');
const suggestedQuestions = useSuggestedQuestions({ const suggestedQuestions = useSuggestedQuestions({
assistantId: askAiConfigurationId, agentId: askAiConfigurationId,
searchClient, searchClient,
suggestedQuestionsEnabled: askAiConfig?.suggestedQuestions, suggestedQuestionsEnabled: askAiConfig?.suggestedQuestions,
}); });
@ -224,10 +224,9 @@ export function DocSearchAskAiModal({
startNewConversation, startNewConversation,
restoreConversation, restoreConversation,
} = useAskAi({ } = useAskAi({
assistantId: askAiConfigurationId, agentId: askAiConfigurationId,
apiKey: askAiConfig?.apiKey || apiKey, apiKey: askAiConfig?.apiKey || apiKey,
appId: askAiConfig?.appId || appId, appId: askAiConfig?.appId || appId,
indexName: askAiConfig?.indexName || defaultIndexName,
searchParameters: askAiSearchParameters, searchParameters: askAiSearchParameters,
tools, tools,
memory: askAiConfig?.memory, memory: askAiConfig?.memory,

View file

@ -6,6 +6,7 @@ import type { ResultsTranslations } from './Results';
import { Results } from './Results'; import { Results } from './Results';
import type { ScreenStateProps } from './ScreenState'; import type { ScreenStateProps } from './ScreenState';
import type { InternalDocSearchHit } from './types'; import type { InternalDocSearchHit } from './types';
import { removeHighlightTags } from './utils';
export type ResultsScreenTranslations = Partial<{ export type ResultsScreenTranslations = Partial<{
askAiPlaceholder: string; askAiPlaceholder: string;
@ -27,19 +28,6 @@ export function ResultsScreen({
resultBadgeKey, resultBadgeKey,
...props ...props
}: ResultsScreenProps): JSX.Element { }: ResultsScreenProps): JSX.Element {
const { resultsSectionTitle = 'Results' } = translations;
const renderIcon = React.useCallback(
({ item }: { item: InternalDocSearchHit }) => {
return (
<div className="DocSearch-Hit-icon">
<SourceIcon type={item.type} />
</div>
);
},
[]
);
const renderAction = React.useCallback(() => { const renderAction = React.useCallback(() => {
return ( return (
<div className="DocSearch-Hit-action"> <div className="DocSearch-Hit-action">
@ -70,14 +58,40 @@ export function ResultsScreen({
return null; return null;
} }
const title = removeHighlightTags(collection.items[0]);
return ( return (
<Results <Results
{...props} {...props}
key={collection.source.sourceId} key={collection.source.sourceId}
translations={translations} translations={translations}
title={resultsSectionTitle} title={title}
collection={collection} collection={collection}
renderIcon={renderIcon} renderIcon={({ item, index }) => (
<>
{item.__docsearch_parent && (
<svg className="DocSearch-Hit-Tree" viewBox="0 0 24 54">
<g
stroke="currentColor"
fill="none"
fillRule="evenodd"
strokeLinecap="round"
strokeLinejoin="round"
>
{item.__docsearch_parent !==
collection.items[index + 1]?.__docsearch_parent ? (
<path d="M8 6v21M20 27H8.3" />
) : (
<path d="M8 6v42M20 27H8.3" />
)}
</g>
</svg>
)}
<div className="DocSearch-Hit-icon">
<SourceIcon type={item.type} />
</div>
</>
)}
renderAction={renderAction} renderAction={renderAction}
renderResultBadge={renderResultBadge} renderResultBadge={renderResultBadge}
/> />

View file

@ -37,17 +37,12 @@ export type SidepanelSearchParameters = {
}; };
export type DocSearchSidepanelProps = DocSearchCallbacks & { export type DocSearchSidepanelProps = DocSearchCallbacks & {
/** The assistant ID to use for the ask AI feature. */ /** The Agent Studio agent ID to use for Ask AI. */
assistantId: string; agentId: string;
/** Public api key with search permissions for the index. */ /** Search only API key. */
apiKey: string; apiKey: string;
/** Algolia application id used by the search client. */ /** Algolia application ID that hosts the Agent Studio agent. */
appId: string; 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 * Configuration for keyboard shortcuts. Allows enabling/disabling specific
* shortcuts. * shortcuts.
@ -103,6 +98,8 @@ function DocSearchSidepanelComponent(
): JSX.Element { ): JSX.Element {
return ( return (
<DocSearch <DocSearch
appId={props.appId}
apiKey={props.apiKey}
keyboardShortcuts={keyboardShortcuts} keyboardShortcuts={keyboardShortcuts}
theme={theme} theme={theme}
ref={ref} ref={ref}
@ -130,8 +127,16 @@ function DocSearchSidepanelComp({
keyboardShortcuts, keyboardShortcuts,
registerView, registerView,
initialAskAiMessage, initialAskAiMessage,
appId,
apiKey,
} = useDocSearch(); } = useDocSearch();
if (!appId || !apiKey) {
throw new Error(
'`Sidepanel` requires `appId` and `apiKey` props or values configured on the `DocSearch` provider.'
);
}
const toggleSidepanelState = React.useCallback(() => { const toggleSidepanelState = React.useCallback(() => {
setDocsearchState(docsearchState === 'sidepanel' ? 'ready' : 'sidepanel'); setDocsearchState(docsearchState === 'sidepanel' ? 'ready' : 'sidepanel');
}, [docsearchState, setDocsearchState]); }, [docsearchState, setDocsearchState]);

View file

@ -134,10 +134,9 @@ function SidepanelInner(
isOpen = false, isOpen = false,
onOpen, onOpen,
onClose, onClose,
assistantId, agentId,
apiKey, apiKey,
appId, appId,
indexName,
variant = 'floating', variant = 'floating',
searchParameters, searchParameters,
pushSelector, pushSelector,
@ -194,8 +193,7 @@ function SidepanelInner(
restoreConversation, restoreConversation,
} = useAskAi({ } = useAskAi({
appId, appId,
indexName, agentId,
assistantId,
apiKey, apiKey,
searchParameters, searchParameters,
tools, tools,
@ -204,7 +202,7 @@ function SidepanelInner(
}); });
const suggestedQuestions = useSuggestedQuestions({ const suggestedQuestions = useSuggestedQuestions({
assistantId, agentId,
suggestedQuestionsEnabled, suggestedQuestionsEnabled,
searchClient, searchClient,
}); });

View file

@ -445,7 +445,7 @@ describe('api', () => {
}, },
}} }}
askAi={{ askAi={{
assistantId: '123', agentId: '123',
promptSuggestions: { promptSuggestions: {
indexName: 'prompt-suggestions', indexName: 'prompt-suggestions',
}, },

View file

@ -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<HTMLInputElement>(),
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(<ResultsScreen {...props} />);
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']);
});
});

View file

@ -157,8 +157,7 @@ describe('useAskAi', () => {
useAskAi({ useAskAi({
apiKey: 'api-key', apiKey: 'api-key',
appId: 'app-id', appId: 'app-id',
assistantId: 'assistant-id', agentId: 'assistant-id',
indexName: 'index-name',
tools: { tools: {
customAction: { customAction: {
onToolCall, onToolCall,
@ -205,8 +204,7 @@ describe('useAskAi', () => {
useAskAi({ useAskAi({
apiKey: 'api-key', apiKey: 'api-key',
appId: 'app-id', appId: 'app-id',
assistantId: 'assistant-id', agentId: 'assistant-id',
indexName: 'index-name',
tools: { tools: {
customAction: { customAction: {
onToolCall, onToolCall,
@ -233,10 +231,9 @@ describe('useAskAi', () => {
useAskAi({ useAskAi({
apiKey: 'api-key', apiKey: 'api-key',
appId: 'app-id', appId: 'app-id',
assistantId: 'assistant-id', agentId: 'assistant-id',
indexName: 'index-name',
tools: {},
memory: { userToken: 'secure-user-token' }, memory: { userToken: 'secure-user-token' },
tools: {},
}) })
); );
@ -250,8 +247,7 @@ describe('useAskAi', () => {
useAskAi({ useAskAi({
apiKey: 'api-key', apiKey: 'api-key',
appId: 'app-id', appId: 'app-id',
assistantId: 'assistant-id', agentId: 'assistant-id',
indexName: 'index-name',
tools: {}, tools: {},
}) })
); );
@ -266,8 +262,7 @@ describe('useAskAi', () => {
useAskAi({ useAskAi({
apiKey: 'api-key', apiKey: 'api-key',
appId: 'app-id', appId: 'app-id',
assistantId: 'assistant-id', agentId: 'assistant-id',
indexName: 'index-name',
tools: {}, tools: {},
}) })
); );
@ -284,8 +279,7 @@ describe('useAskAi', () => {
useAskAi({ useAskAi({
apiKey: 'api-key', apiKey: 'api-key',
appId: 'app-id', appId: 'app-id',
assistantId: 'assistant-id', agentId: 'assistant-id',
indexName: 'index-name',
tools: {}, tools: {},
searchParameters, searchParameters,
}) })
@ -308,8 +302,7 @@ describe('useAskAi', () => {
useAskAi({ useAskAi({
apiKey: 'api-key', apiKey: 'api-key',
appId: 'app-id', appId: 'app-id',
assistantId: 'assistant-id', agentId: 'assistant-id',
indexName: 'index-name',
tools: {}, tools: {},
indices, indices,
}) })
@ -335,8 +328,7 @@ describe('useAskAi', () => {
useAskAi({ useAskAi({
apiKey: 'api-key', apiKey: 'api-key',
appId: 'app-id', appId: 'app-id',
assistantId: 'assistant-id', agentId: 'assistant-id',
indexName: 'index-name',
tools: {}, tools: {},
searchParameters, searchParameters,
indices, indices,
@ -353,8 +345,7 @@ describe('useAskAi', () => {
useAskAi({ useAskAi({
apiKey: 'api-key', apiKey: 'api-key',
appId: 'app-id', appId: 'app-id',
assistantId: 'assistant-id', agentId: 'assistant-id',
indexName: 'index-name',
tools: {}, tools: {},
indices: [], indices: [],
}) })
@ -369,8 +360,7 @@ describe('useAskAi', () => {
useAskAi({ useAskAi({
apiKey: 'api-key', apiKey: 'api-key',
appId: 'app-id', appId: 'app-id',
assistantId: 'assistant-id', agentId: 'assistant-id',
indexName: 'index-name',
tools: {}, tools: {},
}) })
); );
@ -383,8 +373,7 @@ describe('useAskAi', () => {
useAskAi({ useAskAi({
apiKey: 'api-key', apiKey: 'api-key',
appId: 'app-id', appId: 'app-id',
assistantId: 'assistant-id', agentId: 'assistant-id',
indexName: 'index-name',
tools: {}, tools: {},
}) })
); );
@ -408,8 +397,7 @@ describe('useAskAi', () => {
useAskAi({ useAskAi({
apiKey: 'api-key', apiKey: 'api-key',
appId: 'app-id', appId: 'app-id',
assistantId: 'assistant-id', agentId: 'assistant-id',
indexName: 'index-name',
tools: {}, tools: {},
}) })
); );
@ -434,8 +422,7 @@ describe('useAskAi', () => {
useAskAi({ useAskAi({
apiKey: 'api-key', apiKey: 'api-key',
appId: 'app-id', appId: 'app-id',
assistantId: 'assistant-id', agentId: 'assistant-id',
indexName: 'index-name',
tools: {}, tools: {},
}) })
); );
@ -463,8 +450,7 @@ describe('useAskAi', () => {
useAskAi({ useAskAi({
apiKey: 'api-key', apiKey: 'api-key',
appId: 'app-id', appId: 'app-id',
assistantId: 'assistant-id', agentId: 'assistant-id',
indexName: 'index-name',
tools: {}, tools: {},
}) })
); );
@ -492,8 +478,7 @@ describe('useAskAi', () => {
useAskAi({ useAskAi({
apiKey: 'api-key', apiKey: 'api-key',
appId: 'app-id', appId: 'app-id',
assistantId: 'assistant-id', agentId: 'assistant-id',
indexName: 'index-name',
tools: {}, tools: {},
}) })
); );
@ -506,8 +491,7 @@ describe('useAskAi', () => {
useAskAi({ useAskAi({
apiKey: 'api-key', apiKey: 'api-key',
appId: 'app-id', appId: 'app-id',
assistantId: 'assistant-id', agentId: 'assistant-id',
indexName: 'index-name',
tools: {}, tools: {},
}) })
); );
@ -541,8 +525,7 @@ describe('useAskAi', () => {
useAskAi({ useAskAi({
apiKey: 'api-key', apiKey: 'api-key',
appId: 'app-id', appId: 'app-id',
assistantId: 'assistant-id', agentId: 'assistant-id',
indexName: 'index-name',
tools: {}, tools: {},
}) })
); );
@ -578,8 +561,7 @@ describe('useAskAi', () => {
useAskAi({ useAskAi({
apiKey: 'api-key', apiKey: 'api-key',
appId: 'app-id', appId: 'app-id',
assistantId: 'assistant-id', agentId: 'assistant-id',
indexName: 'index-name',
searchParameters, searchParameters,
indices, indices,
tools: {}, tools: {},

View file

@ -3,7 +3,11 @@ import { describe, it, expect, beforeEach, vi } from 'vitest';
import type { AIMessage } from '../types/AskiAi'; import type { AIMessage } from '../types/AskiAi';
import { extractLinksFromMessage } from '../utils/ai'; import { extractLinksFromMessage } from '../utils/ai';
import { createFacetFilters } from '../utils/createDocSearchSources'; import { createFacetFilters } from '../utils/createDocSearchSources';
import { getFacetLabel, normalizeFacets } from '../utils/facets'; import {
deriveDefaultSelectedFacetsFromIndex,
getFacetLabel,
normalizeFacets,
} from '../utils/facets';
import { import {
createObjectStorage, createObjectStorage,
createStorage, 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', () => { it('returns configured facetFilters when no dynamic facets are selected', () => {
expect(createFacetFilters(['language:en'], {})).toEqual(['language:en']); expect(createFacetFilters(['language:en'], {})).toEqual(['language:en']);
}); });
@ -79,10 +111,21 @@ describe('utils', () => {
).toEqual(['docusaurus_tag:default', 'language:en', 'version:v2']); ).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( expect(
createFacetFilters(undefined, { language: '', version: 'v2' }) createFacetFilters(['language:en', 'version:v2'], {
).toEqual(['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']);
}); });
}); });

View file

@ -3,6 +3,7 @@ import React, { type JSX } from 'react';
import type { DocSearchFacet } from '../DocSearch'; import type { DocSearchFacet } from '../DocSearch';
import { ChevronIcon } from '../icons'; import { ChevronIcon } from '../icons';
import { capitalize } from '../utils'; import { capitalize } from '../utils';
import type { FacetSelections } from '../utils/createDocSearchSources';
import { getFacetLabel } from '../utils/facets'; import { getFacetLabel } from '../utils/facets';
import { Chip } from './ui/Chip'; import { Chip } from './ui/Chip';
@ -132,7 +133,7 @@ const SelectedFacetChip = React.memo(function SelectedFacetChip({
value, value,
dismissAriaLabel, dismissAriaLabel,
onDismiss, onDismiss,
}: SelectedFacetChipProps): JSX.Element { }: SelectedFacetChipProps): JSX.Element | null {
const handleDismissFacet = React.useCallback( const handleDismissFacet = React.useCallback(
(e: React.MouseEvent<HTMLButtonElement>) => { (e: React.MouseEvent<HTMLButtonElement>) => {
onDismiss(facetKey, e); onDismiss(facetKey, e);
@ -153,7 +154,7 @@ const SelectedFacetChip = React.memo(function SelectedFacetChip({
interface FacetBarProps { interface FacetBarProps {
facets: FacetBarFacet[]; facets: FacetBarFacet[];
selections: Record<string, string>; selections: FacetSelections;
onSelectionChange: (facet: string, value: string) => void; onSelectionChange: (facet: string, value: string) => void;
clearSelections: () => void; clearSelections: () => void;
translations?: FacetBarTranslations; translations?: FacetBarTranslations;
@ -174,9 +175,15 @@ export const FacetBar = React.memo(function FacetBar({
selectedFacetsAriaLabel = 'Selected search filters', selectedFacetsAriaLabel = 'Selected search filters',
clearFacetAriaLabel = 'Clear filter:', clearFacetAriaLabel = 'Clear filter:',
} = translations; } = translations;
const visibleFacetKeys = React.useMemo(
() => new Set(facets.map((f) => f.key)),
[facets]
);
const selectionsToDisplay = React.useMemo(() => { const selectionsToDisplay = React.useMemo(() => {
return Object.entries(selections).filter(([_, value]) => Boolean(value)); return Object.entries(selections).filter(
}, [selections]); ([key, value]) => Boolean(value) && visibleFacetKeys.has(key)
);
}, [selections, visibleFacetKeys]);
const triggerRefs = React.useRef(new Map<string, HTMLButtonElement>()); const triggerRefs = React.useRef(new Map<string, HTMLButtonElement>());

View file

@ -103,8 +103,8 @@ describe('useDocSearchFacets', () => {
result.current.clearFacetSelections(); result.current.clearFacetSelections();
}); });
expect(result.current.facetSelections).toEqual({}); expect(result.current.facetSelections).toEqual({ language: '' });
expect(result.current.facetSelectionsRef.current).toEqual({}); expect(result.current.facetSelectionsRef.current).toEqual({ language: '' });
expect(onSelectionsChange).toHaveBeenCalledTimes(2); expect(onSelectionsChange).toHaveBeenCalledTimes(2);
}); });

View file

@ -5,7 +5,10 @@ import type { DocSearchFacet, DocSearchIndex } from '../DocSearch';
import { useFacetValues } from '../useFacetValues'; import { useFacetValues } from '../useFacetValues';
import type { useSearchClient } from '../useSearchClient'; import type { useSearchClient } from '../useSearchClient';
import type { FacetSelections } from '../utils/createDocSearchSources'; import type { FacetSelections } from '../utils/createDocSearchSources';
import { normalizeFacets } from '../utils/facets'; import {
deriveDefaultSelectedFacetsFromIndex,
normalizeFacets,
} from '../utils/facets';
export interface UseDocSearchFacetsProps { export interface UseDocSearchFacetsProps {
facets?: DocSearchFacet[]; facets?: DocSearchFacet[];
@ -38,18 +41,18 @@ export function useDocSearchFacets({
() => normalizeFacets(facets), () => normalizeFacets(facets),
[facets] [facets]
); );
const normalizedFacetsRef = React.useRef(normalizedFacets);
const facetValues = useFacetValues({ const facetValues = useFacetValues({
facets: normalizedFacets, facets: normalizedFacets,
indexes, indexes,
searchClient, searchClient,
}); });
const [facetSelections, setFacetSelections] = React.useState<FacetSelections>( const [facetSelections, setFacetSelections] = React.useState<FacetSelections>(
{} () => deriveDefaultSelectedFacetsFromIndex(indexes)
); );
const facetSelectionsRef = React.useRef(facetSelections); const facetSelectionsRef = React.useRef(facetSelections);
const onSelectionsChangeRef = React.useRef(onSelectionsChange); const onSelectionsChangeRef = React.useRef(onSelectionsChange);
onSelectionsChangeRef.current = onSelectionsChange;
const visibleFacets = React.useMemo( const visibleFacets = React.useMemo(
() => () =>
@ -59,6 +62,11 @@ export function useDocSearchFacets({
[facetValues, normalizedFacets] [facetValues, normalizedFacets]
); );
React.useLayoutEffect(() => {
normalizedFacetsRef.current = normalizedFacets;
onSelectionsChangeRef.current = onSelectionsChange;
});
const applySelections = React.useCallback((next: FacetSelections): void => { const applySelections = React.useCallback((next: FacetSelections): void => {
facetSelectionsRef.current = next; facetSelectionsRef.current = next;
setFacetSelections(next); setFacetSelections(next);
@ -67,15 +75,13 @@ export function useDocSearchFacets({
const handleFacetSelectionChange = React.useCallback( const handleFacetSelectionChange = React.useCallback(
(facet: string, value: string): void => { (facet: string, value: string): void => {
if (facetSelectionsRef.current[facet] === value) return; const existing = facetSelectionsRef.current[facet];
if (existing === value) return;
const next = { ...facetSelectionsRef.current }; const next = { ...facetSelectionsRef.current };
if (value === '') { next[facet] = value;
delete next[facet];
} else {
next[facet] = value;
}
applySelections(next); applySelections(next);
}, },
@ -84,7 +90,15 @@ export function useDocSearchFacets({
const clearFacetSelections = React.useCallback(() => { const clearFacetSelections = React.useCallback(() => {
if (Object.keys(facetSelectionsRef.current).length === 0) return; 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]); }, [applySelections]);
return { return {

View file

@ -30,10 +30,9 @@ import type {
type UseChat = UseChatHelpers<AIMessage>; type UseChat = UseChatHelpers<AIMessage>;
type UseAskAiParams = { type UseAskAiParams = {
assistantId: string; agentId: string;
apiKey: string; apiKey: string;
appId: string; appId: string;
indexName: string;
searchParameters?: AgentStudioSearchParameters; searchParameters?: AgentStudioSearchParameters;
tools: ToolCalls; tools: ToolCalls;
memory?: Memory; memory?: Memory;
@ -71,7 +70,7 @@ type UseAskAi = (params: UseAskAiParams) => UseAskAiReturn;
type AgentStudioTransportParams = Pick< type AgentStudioTransportParams = Pick<
UseAskAiParams, UseAskAiParams,
'apiKey' | 'appId' | 'assistantId' 'apiKey' | 'appId' | 'agentId'
> & { > & {
searchParameters?: AgentStudioSearchParameters; searchParameters?: AgentStudioSearchParameters;
userToken?: string; userToken?: string;
@ -81,7 +80,7 @@ type AgentStudioTransportParams = Pick<
const getAgentStudioTransport = ({ const getAgentStudioTransport = ({
appId, appId,
apiKey, apiKey,
assistantId, agentId,
searchParameters, searchParameters,
userToken, userToken,
indices, indices,
@ -100,7 +99,7 @@ const getAgentStudioTransport = ({
} }
return new DefaultChatTransport({ 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: { headers: {
'x-algolia-application-id': appId, 'x-algolia-application-id': appId,
'x-algolia-api-key': apiKey, 'x-algolia-api-key': apiKey,
@ -126,7 +125,7 @@ const getAgentStudioTransport = ({
}; };
export const useAskAi: UseAskAi = ({ export const useAskAi: UseAskAi = ({
assistantId, agentId,
apiKey, apiKey,
appId, appId,
tools = EMPTY_TOOLS, tools = EMPTY_TOOLS,
@ -141,12 +140,12 @@ export const useAskAi: UseAskAi = ({
getAgentStudioTransport({ getAgentStudioTransport({
apiKey, apiKey,
appId, appId,
assistantId, agentId,
searchParameters, searchParameters,
userToken: memory?.userToken, userToken: memory?.userToken,
indices, 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: // Store transport in a ref since it is dependent on unstable dependencies:
@ -224,10 +223,10 @@ export const useAskAi: UseAskAi = ({
const sendFeedback = useCallback<OnAskAiFeedback>( const sendFeedback = useCallback<OnAskAiFeedback>(
async (messageId, { thumbs, tags, notes }): Promise<void> => { async (messageId, { thumbs, tags, notes }): Promise<void> => {
if (!assistantId) return; if (!agentId) return;
const res = await postAgentStudioFeedback({ const res = await postAgentStudioFeedback({
agentId: assistantId, agentId,
vote: thumbs, vote: thumbs,
messageId, messageId,
appId, appId,
@ -244,7 +243,7 @@ export const useAskAi: UseAskAi = ({
{ tags, notes } { tags, notes }
); );
}, },
[assistantId, appId, apiKey, conversations] [agentId, appId, apiKey, conversations]
); );
const onStopStreaming = useCallback(async (): Promise<void> => { const onStopStreaming = useCallback(async (): Promise<void> => {

View file

@ -32,7 +32,6 @@ export function useFacetValues({
const facetKeys = stableFacetKeys ? stableFacetKeys.split(',') : []; const facetKeys = stableFacetKeys ? stableFacetKeys.split(',') : [];
if (facetKeys.length === 0 || indexes.length === 0) { if (facetKeys.length === 0 || indexes.length === 0) {
setFacetValues({});
return () => { return () => {
isMounted = false; isMounted = false;
}; };
@ -41,7 +40,6 @@ export function useFacetValues({
searchClient searchClient
.search<DocSearchHit>({ .search<DocSearchHit>({
requests: indexes.map((index) => ({ requests: indexes.map((index) => ({
...index.searchParameters,
indexName: index.name, indexName: index.name,
query: '', query: '',
hitsPerPage: 0, hitsPerPage: 0,

View file

@ -10,13 +10,13 @@ import type {
} from '.'; } from '.';
type UseSuggestedQuestionsProps = { type UseSuggestedQuestionsProps = {
assistantId: string | null; agentId: string | null;
searchClient: DocSearchTransformClient; searchClient: DocSearchTransformClient;
suggestedQuestionsEnabled?: boolean; suggestedQuestionsEnabled?: boolean;
}; };
export const useSuggestedQuestions = ({ export const useSuggestedQuestions = ({
assistantId, agentId,
searchClient, searchClient,
suggestedQuestionsEnabled = false, suggestedQuestionsEnabled = false,
}: UseSuggestedQuestionsProps): SuggestedQuestionHit[] => { }: UseSuggestedQuestionsProps): SuggestedQuestionHit[] => {
@ -30,7 +30,7 @@ export const useSuggestedQuestions = ({
requests: [ requests: [
{ {
indexName: SUGGESTED_QUETIONS_INDEX_NAME, indexName: SUGGESTED_QUETIONS_INDEX_NAME,
filters: `state:published AND assistantId:${assistantId}`, filters: `state:published AND assistantId:${agentId}`,
hitsPerPage: 3, hitsPerPage: 3,
}, },
], ],
@ -41,10 +41,10 @@ export const useSuggestedQuestions = ({
setSuggestedQuestions(result.hits); setSuggestedQuestions(result.hits);
}; };
if (suggestedQuestionsEnabled && assistantId && assistantId !== '') { if (suggestedQuestionsEnabled && agentId && agentId !== '') {
getSuggestedQuestions(); getSuggestedQuestions();
} }
}, [suggestedQuestionsEnabled, assistantId, searchClient]); }, [suggestedQuestionsEnabled, agentId, searchClient]);
return suggestedQuestions; return suggestedQuestions;
}; };

View file

@ -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'
);
});
});

View file

@ -2,7 +2,11 @@ import type {
AutocompleteSource, AutocompleteSource,
AutocompleteState, AutocompleteState,
} from '@algolia/autocomplete-core'; } 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 React from 'react';
import type { DocSearchIndex, DocSearchProps } from '../DocSearch'; import type { DocSearchIndex, DocSearchProps } from '../DocSearch';
@ -34,19 +38,43 @@ export function createFacetFilters(
searchParametersFacetFilters: SearchParamsObject['facetFilters'], searchParametersFacetFilters: SearchParamsObject['facetFilters'],
facetSelections: FacetSelections facetSelections: FacetSelections
): SearchParamsObject['facetFilters'] { ): SearchParamsObject['facetFilters'] {
const dynamicFacetFilters = Object.entries(facetSelections) const selections = Object.entries(facetSelections);
.filter(([, value]) => value) const selectedFacets = new Set(selections.map(([facet]) => facet));
.map(([facet, value]) => `${facet}:${value}`); 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; return searchParametersFacetFilters;
} }
if (!searchParametersFacetFilters) { let configuredFacetFilters: FacetFilters = [];
return dynamicFacetFilters;
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({ export function buildNoQuerySources({
@ -221,10 +249,8 @@ export async function buildQuerySources({
}; };
} }
const items = Object.values(sources).flat(); return Object.values<DocSearchHit[]>(sources).map((items, index) => ({
sourceId: `hits_${result.index}_${index}`,
return {
sourceId: `hits_${result.index}`,
onSelect({ item, event }): void { onSelect({ item, event }): void {
saveRecentSearch(item); saveRecentSearch(item);
if (!isModifierEvent(event)) { if (!isModifierEvent(event)) {
@ -234,36 +260,34 @@ export async function buildQuerySources({
getItemUrl({ item }): string { getItemUrl({ item }): string {
return item.url; return item.url;
}, },
getItems() { getItems(): InternalDocSearchHit[] {
return Object.values( return Object.values(
groupBy(items, (item) => item.hierarchy.lvl1, maxResultsPerGroup) groupBy(items, (item) => item.hierarchy.lvl1, maxResultsPerGroup)
) )
.map((groupedHits) => .map((groupedHits) =>
groupedHits groupedHits.map((item) => {
.map((item) => { let parent: InternalDocSearchHit | null = null;
let parent: InternalDocSearchHit | null = null;
const potentialParent = groupedHits.find( const potentialParent = groupedHits.find(
(siblingItem) => (siblingItem) =>
siblingItem.type === 'lvl1' && siblingItem.type === 'lvl1' &&
siblingItem.hierarchy.lvl1 === item.hierarchy.lvl1 siblingItem.hierarchy.lvl1 === item.hierarchy.lvl1
) as InternalDocSearchHit | undefined; ) as InternalDocSearchHit | undefined;
if (item.type !== 'lvl1' && potentialParent) { if (item.type !== 'lvl1' && potentialParent) {
parent = potentialParent; parent = potentialParent;
} }
return { return {
...item, ...item,
__docsearch_parent: parent, __docsearch_parent: parent,
...insightsParams, ...insightsParams,
}; };
}) })
.flat()
) )
.flat(); .flat();
}, },
}; }));
}); });
} catch (error) { } catch (error) {
if ((error as Error).name === 'RetryError') { if ((error as Error).name === 'RetryError') {

View file

@ -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; export const MAX_FACETS = 5;
@ -40,3 +42,36 @@ export function getFacetLabel(facet: DocSearchFacet): string {
.replace(/[._-]+/g, ' ') .replace(/[._-]+/g, ' ')
.replace(/\b\w/g, (letter) => letter.toUpperCase()); .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;
}

View file

@ -36,9 +36,8 @@ function App() {
<SidepanelButton /> <SidepanelButton />
<Sidepanel <Sidepanel
appId="YOUR_APP_ID" appId="YOUR_APP_ID"
indexName="YOUR_INDEX_NAME"
apiKey="YOUR_SEARCH_API_KEY" apiKey="YOUR_SEARCH_API_KEY"
assistantId="YOUR_ASK_AI_ASSISTANT_ID" agentId="YOUR_ASK_AI_ASSISTANT_ID"
/> />
</DocSearch> </DocSearch>
); );

View file

@ -9,7 +9,11 @@ import type { JSX } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
export type SidepanelProps = DocSearchSidepanelProps['panel'] & export type SidepanelProps = DocSearchSidepanelProps['panel'] &
Omit<DocSearchSidepanelProps, 'button' | 'panel' | 'theme'> & Omit<
DocSearchSidepanelProps,
'appId' | 'apiKey' | 'button' | 'panel' | 'theme'
> &
Partial<Pick<DocSearchSidepanelProps, 'appId' | 'apiKey'>> &
SidepanelSearchParameters; SidepanelSearchParameters;
export function Sidepanel({ export function Sidepanel({
@ -17,6 +21,8 @@ export function Sidepanel({
...props ...props
}: SidepanelProps): JSX.Element { }: SidepanelProps): JSX.Element {
const { const {
appId: providerAppId,
apiKey: providerApiKey,
docsearchState, docsearchState,
setDocsearchState, setDocsearchState,
keyboardShortcuts, keyboardShortcuts,
@ -24,6 +30,15 @@ export function Sidepanel({
initialAskAiMessage, initialAskAiMessage,
} = useDocSearch(); } = 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 => { const handleOpen = React.useCallback((): void => {
setDocsearchState('sidepanel'); setDocsearchState('sidepanel');
}, [setDocsearchState]); }, [setDocsearchState]);
@ -49,12 +64,16 @@ export function Sidepanel({
keyboardShortcuts, keyboardShortcuts,
initialMessage: initialAskAiMessage, initialMessage: initialAskAiMessage,
...props, ...props,
appId,
apiKey,
}), }),
[ [
docsearchState, docsearchState,
handleOpen, handleOpen,
handleClose, handleClose,
props, props,
appId,
apiKey,
keyboardShortcuts, keyboardShortcuts,
initialAskAiMessage, initialAskAiMessage,
] ]

View file

@ -64,7 +64,7 @@ bun add @docsearch/core@^5.0.0-beta @docsearch/modal@^5.0.0-beta @docsearch/css@
## Add keyword search ## 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" ```tsx title="KeywordSearch.tsx"
import { DocSearch } from '@docsearch/core'; import { DocSearch } from '@docsearch/core';
@ -85,14 +85,14 @@ export function KeywordSearch({
indexName, indexName,
}: KeywordSearchProps): JSX.Element { }: KeywordSearchProps): JSX.Element {
return ( return (
<DocSearch> <DocSearch appId={appId} apiKey={apiKey}>
<DocSearchButton <DocSearchButton
translations={{ translations={{
buttonText: 'Search docs', buttonText: 'Search docs',
buttonAriaLabel: 'Search documentation', buttonAriaLabel: 'Search documentation',
}} }}
/> />
<DocSearchModal appId={appId} apiKey={apiKey} indices={[indexName]} /> <DocSearchModal indices={[indexName]} />
</DocSearch> </DocSearch>
); );
} }
@ -102,7 +102,7 @@ The provider opens the modal when a user selects the button, presses <kbd>Ctrl</
## Add keyword search and Ask AI ## Add keyword search and Ask AI
Replace `DocSearchModal` with `DocSearchAskAiModal`. Create the assistant in [Agent Studio](/docs/agent-studio/getting-started), then pass its ID through `askAi`. Replace `DocSearchModal` with `DocSearchAskAiModal`. Create the assistant in [Agent Studio](/docs/agent-studio/getting-started), then configure its ID on the provider.
```tsx title="SearchWithAskAi.tsx" ```tsx title="SearchWithAskAi.tsx"
import { DocSearch } from '@docsearch/core'; import { DocSearch } from '@docsearch/core';
@ -115,30 +115,28 @@ interface SearchWithAskAiProps {
appId: string; appId: string;
apiKey: string; apiKey: string;
indexName: string; indexName: string;
assistantId: string; agentId: string;
} }
export function SearchWithAskAi({ export function SearchWithAskAi({
appId, appId,
apiKey, apiKey,
indexName, indexName,
assistantId, agentId,
}: SearchWithAskAiProps): JSX.Element { }: SearchWithAskAiProps): JSX.Element {
return ( return (
<DocSearch> <DocSearch appId={appId} apiKey={apiKey}>
<DocSearchButton /> <DocSearchButton />
<DocSearchAskAiModal <DocSearchAskAiModal
appId={appId}
apiKey={apiKey}
indices={[indexName]} indices={[indexName]}
askAi={{ assistantId }} askAi={agentId}
/> />
</DocSearch> </DocSearch>
); );
} }
``` ```
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 ## Understand the shared state
@ -200,7 +198,7 @@ interface ControlledSearchProps {
appId: string; appId: string;
apiKey: string; apiKey: string;
indexName: string; indexName: string;
assistantId: string; agentId: string;
onReady?: () => void; onReady?: () => void;
onOpen?: () => void; onOpen?: () => void;
onClose?: () => void; onClose?: () => void;
@ -231,7 +229,7 @@ export function ControlledSearch(props: ControlledSearchProps): JSX.Element {
appId={props.appId} appId={props.appId}
apiKey={props.apiKey} apiKey={props.apiKey}
indices={[props.indexName]} indices={[props.indexName]}
askAi={{ assistantId: props.assistantId }} askAi={props.agentId}
/> />
</DocSearch> </DocSearch>
); );
@ -339,7 +337,7 @@ interface LazySearchProps {
appId: string; appId: string;
apiKey: string; apiKey: string;
indexName: string; indexName: string;
assistantId: string; agentId: string;
} }
export function LazySearch(props: LazySearchProps): JSX.Element { export function LazySearch(props: LazySearchProps): JSX.Element {
@ -354,7 +352,7 @@ export function LazySearch(props: LazySearchProps): JSX.Element {
appId={props.appId} appId={props.appId}
apiKey={props.apiKey} apiKey={props.apiKey}
indices={[props.indexName]} indices={[props.indexName]}
askAi={{ assistantId: props.assistantId }} askAi={props.agentId}
/> />
</DocSearch> </DocSearch>
); );
@ -401,12 +399,12 @@ Add `@docsearch/css/dist/_askai.css` when you use `DocSearchAskAiModal`. Bundler
| API | Required configuration | Provider-managed behavior | | 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 | | `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 | | `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 | | `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`. `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.

View file

@ -202,7 +202,7 @@ V5 adds `askAi.indices`, `askAi.tools`, `askAi.memory`, and `askAi.promptSuggest
## 4. Move keyword configuration to `indices` ## 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" ```diff title="app.js"
docsearch({ 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 ## 5. Update result customization

View file

@ -43,7 +43,7 @@ Public API key with search permission.
> `type: Array<string | DocSearchIndex>` | **optional** > `type: Array<string | DocSearchIndex>` | **optional**
Indices to search in display order. Provide `indices` or the deprecated [`indexName`](#indexname). Indices to search in display order.
```tsx title="Search.tsx" ```tsx title="Search.tsx"
<DocSearch <DocSearch
@ -61,12 +61,6 @@ Indices to search in display order. Provide `indices` or the deprecated [`indexN
/> />
``` ```
### `indexName`
> `type: string` | **optional** | **deprecated**
Single index to search. Use [`indices`](#indices) instead. If you pass both options, DocSearch queries `indexName` first.
### `facets` ### `facets`
> `type: DocSearchFacet[]` | **optional** > `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. 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` ### `maxResultsPerGroup`
> `type: number` | **optional** > `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`. Agent Studio assistant ID or configuration. This prop is required by `DocSearchAI`.
#### `assistantId` #### `agentId`
> `type: string` | **required** > `type: string` | **required**
Agent Studio assistant ID. Agent Studio agent ID.
#### `appId` #### `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`. 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` #### `suggestedQuestions`
> `type: boolean` | **optional** > `type: boolean` | **optional**
@ -289,7 +271,7 @@ Search parameters keyed by index name. Each value supports `filters`, `attribute
```tsx title="Search.tsx" ```tsx title="Search.tsx"
<DocSearchAI <DocSearchAI
askAi={{ askAi={{
assistantId: 'YOUR_ASSISTANT_ID', agentId: 'YOUR_AGENT_ID',
searchParameters: { searchParameters: {
docs: { docs: {
filters: 'language:en', filters: 'language:en',
@ -392,7 +374,6 @@ Opens a registered Sidepanel. Does nothing when no Sidepanel is registered.
- `react`, `react-dom`, and `@types/react` support versions `>=16.8.0 <20.0.0`. - `react`, `react-dom`, and `@types/react` support versions `>=16.8.0 <20.0.0`.
- `search-insights` supports versions `>=1 <3` and is optional. - `search-insights` supports versions `>=1 <3` and is optional.
- The browser build targets ES2017. - 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. - `UseDocSearchKeyboardEventsProps.onInput` and `searchButtonRef` remain accepted for compatibility but are deprecated.
- `DocSearch` is keyword-only in v5. Use `DocSearchAI` for Ask AI. - `DocSearch` is keyword-only in v5. Use `DocSearchAI` for Ask AI.

View file

@ -82,7 +82,7 @@ export default {
apiKey: '24b09689d5b4223813d9b8e48563c8f6', apiKey: '24b09689d5b4223813d9b8e48563c8f6',
indices: [{ name: 'docsearch' }], indices: [{ name: 'docsearch' }],
askAi: { askAi: {
assistantId: 'ccdec697-e3fe-465b-a1c3-657e7bf18aef', agentId: 'ccdec697-e3fe-465b-a1c3-657e7bf18aef',
}, },
sidePanel: true, sidePanel: true,
contextualSearch: true, contextualSearch: true,

View file

@ -80,17 +80,26 @@ export default function DemoApp() {
HTMLElement.prototype.focus = function focusNoScroll(options) { HTMLElement.prototype.focus = function focusNoScroll(options) {
if (!userEngaged) return undefined; if (!userEngaged) return undefined;
return nativeFocus.call(this, { preventScroll: true, ...(options ?? {}) }); return nativeFocus.call(this, {
preventScroll: true,
...(options ?? {}),
});
}; };
Element.prototype.scrollIntoView = function containedScrollIntoView(arg) { 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; let ancestor = this.parentElement;
while (ancestor) { while (ancestor) {
const overflowY = getComputedStyle(ancestor).overflowY; 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 = const top =
this.getBoundingClientRect().top - ancestor.getBoundingClientRect().top + ancestor.scrollTop; this.getBoundingClientRect().top -
ancestor.getBoundingClientRect().top +
ancestor.scrollTop;
ancestor.scrollTo({ top, behavior }); ancestor.scrollTo({ top, behavior });
return; return;
} }
@ -164,10 +173,9 @@ export default function DemoApp() {
<DocSearchSidepanel <DocSearchSidepanel
ref={sidepanelRef} ref={sidepanelRef}
theme={theme} theme={theme}
indexName={INDEX_NAME}
appId={APP_ID} appId={APP_ID}
apiKey={API_KEY} apiKey={API_KEY}
assistantId={ASSISTANT_ID} agentId={ASSISTANT_ID}
button={{ variant: 'inline' }} button={{ variant: 'inline' }}
panel={{ suggestedQuestions: true }} panel={{ suggestedQuestions: true }}
/> />
@ -186,7 +194,7 @@ export default function DemoApp() {
indices={[INDEX_NAME]} indices={[INDEX_NAME]}
appId={APP_ID} appId={APP_ID}
apiKey={API_KEY} apiKey={API_KEY}
askAi={{ assistantId: ASSISTANT_ID }} askAi={ASSISTANT_ID}
navigator={navigator} navigator={navigator}
/> />
</DocSearch> </DocSearch>

View file

@ -31,7 +31,7 @@ npm install @docsearch/react@4 @docsearch/css@4
import { DocSearch } from '@docsearch/react'; import { DocSearch } from '@docsearch/react';
import '@docsearch/css'; import '@docsearch/css';
<DocSearch appId="YOUR_APP_ID" indexName="YOUR_INDEX" apiKey="YOUR_SEARCH_KEY" /> <DocSearch appId="YOUR_APP_ID" indices=["YOUR_INDEX"] apiKey="YOUR_SEARCH_KEY" />
\`\`\` \`\`\`
### Docusaurus (recommended) ### Docusaurus (recommended)
@ -47,7 +47,7 @@ themeConfig: {
docsearch: { docsearch: {
appId: 'YOUR_APP_ID', appId: 'YOUR_APP_ID',
apiKey: 'YOUR_SEARCH_KEY', apiKey: 'YOUR_SEARCH_KEY',
indexName: 'YOUR_INDEX', indices: ['YOUR_INDEX'],
}, },
}, },
\`\`\` \`\`\`