feat(askai): Split Ask AI modal into own component (#2884)
* feat(askai): Split Ask AI modal into own component * refactor(react): share modal utilities * refactor(react): share search box form * refactor(react): extract start screen sections * refactor(react): extract shared modal hooks * fix: lint adapter * refactor(react): reorganize modal files * fix: type error in examples * fix: remove ai modal from adapter for now, fix import paths of react package
This commit is contained in:
parent
c9f49e1fd2
commit
83759e1e0f
54 changed files with 2549 additions and 1172 deletions
10
AGENTS.md
10
AGENTS.md
|
|
@ -140,6 +140,14 @@ function createStorage<TItem>(key: string): StorageInterface<TItem> {
|
|||
- Use `React.useMemo` for expensive computations
|
||||
- Prefer destructuring props in function signature
|
||||
|
||||
### React Component Structure
|
||||
|
||||
- `src/components/ui/` contains reusable rendering components.
|
||||
- Prefer domain-light primitives in `src/components/ui/` when possible.
|
||||
- Feature-scoped UI components may live in `src/components/ui/` when their feature scope is explicit in the filename, such as `RecentConversationsResults.tsx`.
|
||||
- `src/components/` contains scoped composition components that own feature flow, branching, and state orchestration.
|
||||
- Keep AI-specific behavior out of generic UI primitives. If a UI component is AI-specific, make that scope clear in its name.
|
||||
|
||||
```typescript
|
||||
function DocSearchComponent(
|
||||
props: DocSearchProps,
|
||||
|
|
@ -226,6 +234,8 @@ describe('ComponentName', () => {
|
|||
packages/
|
||||
docsearch-react/
|
||||
src/
|
||||
components/ # Scoped React composition components
|
||||
ui/ # Reusable rendering components and explicitly scoped UI pieces
|
||||
__tests__/ # Test files
|
||||
icons/ # Icon components
|
||||
types/ # Type definitions
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
*/
|
||||
|
||||
import type { AskAiConfig } from '@docsearch/docusaurus-adapter';
|
||||
import type { DocSearchModalProps, DocSearchTranslations } from '@docsearch/react';
|
||||
import type { DocSearchAskAiModalProps, DocSearchTranslations } from '@docsearch/react';
|
||||
import translations from '@theme/SearchTranslations';
|
||||
import type { FacetFilters } from 'algoliasearch/lite';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
|
@ -21,14 +21,14 @@ interface DocSearchPropsLite {
|
|||
appId: string;
|
||||
placeholder?: string;
|
||||
translations?: DocSearchTranslations;
|
||||
searchParameters?: DocSearchModalProps['searchParameters'];
|
||||
searchParameters?: DocSearchAskAiModalProps['searchParameters'];
|
||||
askAi?: AskAiConfig;
|
||||
}
|
||||
|
||||
type OnAskAiToggle = NonNullable<DocSearchModalProps['onAskAiToggle']>;
|
||||
type OnAskAiToggle = NonNullable<DocSearchAskAiModalProps['onAskAiToggle']>;
|
||||
type AskAiConfigWithoutSidePanel = Omit<AskAiConfig, 'sidePanel'>;
|
||||
type DocSearchAskAi = Exclude<DocSearchModalProps['askAi'], string | undefined>;
|
||||
type DocSearchModalPropsLite = Partial<Omit<DocSearchModalProps, 'askAi'>>;
|
||||
type DocSearchAskAi = Exclude<DocSearchAskAiModalProps['askAi'], string | undefined>;
|
||||
type DocSearchModalPropsLite = Partial<Omit<DocSearchAskAiModalProps, 'askAi'>>;
|
||||
|
||||
type UseAskAiResult = {
|
||||
canHandleAskAi: boolean;
|
||||
|
|
@ -96,7 +96,7 @@ export function useAlgoliaAskAi(props: DocSearchPropsLite): UseAskAiResult {
|
|||
const canHandleAskAi = Boolean(askAi);
|
||||
|
||||
const currentPlaceholder = isAskAiActive
|
||||
? translations.modal?.searchBox?.placeholderTextAskAi
|
||||
? (translations.modal?.searchBox as { placeholderTextAskAi?: string } | undefined)?.placeholderTextAskAi
|
||||
: translations.modal?.searchBox?.placeholderText || props?.placeholder;
|
||||
|
||||
const onAskAiToggle = useCallback<OnAskAiToggle>((askAiToggle: boolean) => {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
/* eslint-disable import/dynamic-import-chunkname */
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. And its affiliates.
|
||||
*
|
||||
|
|
@ -9,6 +10,7 @@ import type { AutocompleteState } from '@algolia/autocomplete-core';
|
|||
import type { ThemeConfigAlgolia } from '@docsearch/docusaurus-adapter';
|
||||
import type {
|
||||
InternalDocSearchHit,
|
||||
DocSearchAskAiModalProps,
|
||||
DocSearchModal as DocSearchModalType,
|
||||
DocSearchModalProps,
|
||||
StoredDocSearchHit,
|
||||
|
|
@ -45,16 +47,20 @@ type DocSearchProps = Omit<DocSearchModalProps, 'initialScrollY' | 'onClose'> &
|
|||
contextualSearch?: string;
|
||||
externalUrlRegex?: string;
|
||||
searchPagePath: boolean | string;
|
||||
askAi?: Exclude<(DocSearchModalProps & { askAi: unknown })['askAi'], string | undefined>;
|
||||
askAi?: Exclude<DocSearchAskAiModalProps['askAi'], string | undefined>;
|
||||
};
|
||||
|
||||
type AskAiTogglePayload = {
|
||||
query: string;
|
||||
messageId?: string;
|
||||
suggestedQuestionId?: string;
|
||||
};
|
||||
// eslint-disable-next-line no-warning-comments
|
||||
// TODO: Will be handled later with refactor to use DocSearchAskAiModal
|
||||
// type AskAiTogglePayload = {
|
||||
// query: string;
|
||||
// messageId?: string;
|
||||
// suggestedQuestionId?: string;
|
||||
// };
|
||||
|
||||
type OnAskAiToggle = (toggle: boolean, payload?: AskAiTogglePayload) => void;
|
||||
// eslint-disable-next-line no-warning-comments
|
||||
// TODO: Will be handled later with refactor to use DocSearchAskAiModal
|
||||
// type OnAskAiToggle = (toggle: boolean, payload?: AskAiTogglePayload) => void;
|
||||
type NavigatorNavigateParams = Parameters<NonNullable<NonNullable<DocSearchModalProps['navigator']>['navigate']>>[0];
|
||||
|
||||
interface AlgoliaSearchBarProps extends Omit<DocSearchProps, 'askAi'> {
|
||||
|
|
@ -71,7 +77,6 @@ function importDocSearchModalIfNeeded(): Promise<void> {
|
|||
return Promise.resolve();
|
||||
}
|
||||
|
||||
// eslint-disable-next-line import/dynamic-import-chunkname
|
||||
return Promise.all([import('@docsearch/react/modal'), import('@docsearch/react/style'), import('./styles.css')]).then(
|
||||
([{ DocSearchModal: Modal }]) => {
|
||||
DocSearchModal = Modal;
|
||||
|
|
@ -85,7 +90,6 @@ async function importDocSearchSidepanelIfNeeded(): Promise<void> {
|
|||
return Promise.resolve();
|
||||
}
|
||||
|
||||
// eslint-disable-next-line import/dynamic-import-chunkname
|
||||
return Promise.all([import('@docsearch/react/sidepanel'), import('@docsearch/react/style/sidepanel')]).then(
|
||||
([{ Sidepanel }]) => {
|
||||
DocSearchSidepanel = Sidepanel;
|
||||
|
|
@ -252,17 +256,19 @@ function DocSearch({ externalUrlRegex, ...props }: AlgoliaSearchBarProps) {
|
|||
onAskAiToggle(false);
|
||||
}, [onAskAiToggle]);
|
||||
|
||||
const handleAskAiToggle = useCallback<OnAskAiToggle>(
|
||||
(active, payload) => {
|
||||
if (active && sidePanelEnabled) {
|
||||
closeModal();
|
||||
openSidepanel(payload);
|
||||
return;
|
||||
}
|
||||
onAskAiToggle(active);
|
||||
},
|
||||
[closeModal, onAskAiToggle, openSidepanel, sidePanelEnabled],
|
||||
);
|
||||
// eslint-disable-next-line no-warning-comments
|
||||
// TODO: Will be handled later with refactor to use DocSearchAskAiModal
|
||||
// const handleAskAiToggle = useCallback<OnAskAiToggle>(
|
||||
// (active, payload) => {
|
||||
// if (active && sidePanelEnabled) {
|
||||
// closeModal();
|
||||
// openSidepanel(payload);
|
||||
// return;
|
||||
// }
|
||||
// onAskAiToggle(active);
|
||||
// },
|
||||
// [closeModal, onAskAiToggle, openSidepanel, sidePanelEnabled],
|
||||
// );
|
||||
|
||||
// cleanup search container
|
||||
useEffect(() => {
|
||||
|
|
@ -342,33 +348,33 @@ function DocSearch({ externalUrlRegex, ...props }: AlgoliaSearchBarProps) {
|
|||
DocSearchModal &&
|
||||
searchContainer.current &&
|
||||
createPortal(
|
||||
<DocSearchModal
|
||||
initialScrollY={window.scrollY}
|
||||
initialQuery={initialQuery}
|
||||
navigator={navigator}
|
||||
transformItems={transformItems}
|
||||
hitComponent={Hit}
|
||||
transformSearchClient={transformSearchClient}
|
||||
interceptAskAiEvent={(payload) => {
|
||||
React.createElement(DocSearchModal, {
|
||||
initialScrollY: window.scrollY,
|
||||
initialQuery,
|
||||
navigator,
|
||||
transformItems,
|
||||
hitComponent: Hit,
|
||||
transformSearchClient,
|
||||
interceptAskAiEvent: (
|
||||
payload: Parameters<NonNullable<DocSearchAskAiModalProps['interceptAskAiEvent']>>[0],
|
||||
) => {
|
||||
if (!sidePanelEnabled) {
|
||||
return false;
|
||||
}
|
||||
closeModal();
|
||||
openSidepanel(payload);
|
||||
return true;
|
||||
}}
|
||||
onClose={closeModal}
|
||||
{...(resultsFooterSearchPagePath && {
|
||||
},
|
||||
onClose: closeModal,
|
||||
...(resultsFooterSearchPagePath && {
|
||||
resultsFooterComponent,
|
||||
})}
|
||||
placeholder={currentPlaceholder}
|
||||
{...(props as DocSearchProps)}
|
||||
translations={props.translations?.modal ?? translations.modal}
|
||||
searchParameters={searchParameters}
|
||||
{...extraAskAiProps}
|
||||
isHybridModeSupported={sidePanelEnabled}
|
||||
onAskAiToggle={handleAskAiToggle as DocSearchModalProps['onAskAiToggle']}
|
||||
/>,
|
||||
}),
|
||||
placeholder: currentPlaceholder,
|
||||
...(props as DocSearchProps),
|
||||
translations: props.translations?.modal ?? translations.modal,
|
||||
searchParameters,
|
||||
...extraAskAiProps,
|
||||
}),
|
||||
searchContainer.current,
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
/* eslint-disable react/react-in-jsx-scope */
|
||||
import { DocSearch } from '@docsearch/core';
|
||||
import { DocSearchButton } from '@docsearch/modal/button';
|
||||
import { DocSearchModal } from '@docsearch/modal/modal';
|
||||
import { DocSearchButton, DocSearchAskAiModal } from '@docsearch/modal';
|
||||
import type { JSX } from 'react';
|
||||
|
||||
export default function AgentStudio(): JSX.Element {
|
||||
|
|
@ -12,7 +11,7 @@ export default function AgentStudio(): JSX.Element {
|
|||
buttonText: 'Ask AI with Agent Studio',
|
||||
}}
|
||||
/>
|
||||
<DocSearchModal
|
||||
<DocSearchAskAiModal
|
||||
indexName="docsearch"
|
||||
appId="PMZUYBQDAK"
|
||||
apiKey="a00716d83c64f6c61905c078b7d5ab66"
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
/* eslint-disable react/react-in-jsx-scope */
|
||||
import { DocSearch } from '@docsearch/react';
|
||||
import { DocSearchAI } from '@docsearch/react';
|
||||
import type { JSX } from 'react';
|
||||
|
||||
export default function BasicAskAI(): JSX.Element {
|
||||
return (
|
||||
<DocSearch
|
||||
<DocSearchAI
|
||||
indexName="docsearch"
|
||||
appId="PMZUYBQDAK"
|
||||
apiKey="24b09689d5b4223813d9b8e48563c8f6"
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
/* eslint-disable react/react-in-jsx-scope */
|
||||
import { DocSearch } from '@docsearch/core';
|
||||
import { DocSearchButton, DocSearchModal } from '@docsearch/modal';
|
||||
import { DocSearchButton, DocSearchAskAiModal } from '@docsearch/modal';
|
||||
import { type JSX } from 'react';
|
||||
|
||||
export default function Composable(): JSX.Element {
|
||||
return (
|
||||
<DocSearch>
|
||||
<DocSearchButton translations={{ buttonText: 'Composable API' }} />
|
||||
<DocSearchModal
|
||||
<DocSearchAskAiModal
|
||||
indexName="docsearch"
|
||||
appId="PMZUYBQDAK"
|
||||
apiKey="24b09689d5b4223813d9b8e48563c8f6"
|
||||
|
|
|
|||
|
|
@ -1,18 +1,18 @@
|
|||
/* eslint-disable react/react-in-jsx-scope */
|
||||
import { useDocSearchKeyboardEvents } from '@docsearch/core/useDocSearchKeyboardEvents';
|
||||
import type { DocSearchAskAiModal as DocSearchAskAiModalType } from '@docsearch/react/askaiModal';
|
||||
import { DocSearchButton } from '@docsearch/react/button';
|
||||
import type { DocSearchModal as DocSearchModalType } from '@docsearch/react/modal';
|
||||
import { useCallback, useRef, useState, type JSX } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
let DocSearchModal: typeof DocSearchModalType | null = null;
|
||||
let DocSearchModal: typeof DocSearchAskAiModalType | null = null;
|
||||
|
||||
function importDocSearchModalIfNeeded(): Promise<void> {
|
||||
if (DocSearchModal) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
// eslint-disable-next-line import/dynamic-import-chunkname
|
||||
return Promise.all([import('@docsearch/react/modal')]).then(([{ DocSearchModal: Modal }]) => {
|
||||
return Promise.all([import('@docsearch/react/askaiModal')]).then(([{ DocSearchAskAiModal: Modal }]) => {
|
||||
DocSearchModal = Modal;
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
/* eslint-disable react/react-in-jsx-scope */
|
||||
import { DocSearch } from '@docsearch/core';
|
||||
import { DocSearchButton, DocSearchModal } from '@docsearch/modal';
|
||||
import { DocSearchButton, DocSearchAskAiModal } from '@docsearch/modal';
|
||||
import { Sidepanel, SidepanelButton } from '@docsearch/sidepanel';
|
||||
import type { JSX } from 'react';
|
||||
|
||||
|
|
@ -8,7 +8,7 @@ export default function BasicHybrid(): JSX.Element {
|
|||
return (
|
||||
<DocSearch>
|
||||
<DocSearchButton />
|
||||
<DocSearchModal
|
||||
<DocSearchAskAiModal
|
||||
indexName="docsearch"
|
||||
appId="beta3G7FSQDJR3"
|
||||
apiKey="0faad3eae2ba413c16355a0f8670c201"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
/* eslint-disable react/react-in-jsx-scope */
|
||||
import { DocSearch } from '@docsearch/react';
|
||||
import { DocSearchAI } from '@docsearch/react';
|
||||
import type { JSX } from 'react';
|
||||
|
||||
// this type matches the structure of the provided example hit
|
||||
|
|
@ -21,7 +21,7 @@ import type { JSX } from 'react';
|
|||
|
||||
export default function WTransformItems(): JSX.Element {
|
||||
return (
|
||||
<DocSearch
|
||||
<DocSearchAI
|
||||
indexName="crawler_doc"
|
||||
appId="PMZUYBQDAK"
|
||||
apiKey="24b09689d5b4223813d9b8e48563c8f6"
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@
|
|||
"pw:ui": "start-server-and-test 'bun run website:test' http://localhost:3000 'playwright test --ui'",
|
||||
"lint:css": "stylelint **/src/**/*.css",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint --fix .",
|
||||
"playground:build": "bun --filter @docsearch/react-example build",
|
||||
"playground:start": "bun --filter @docsearch/react-example dev -- --host",
|
||||
"playground-js:start": "bun --filter @docsearch/js-example dev -- --host",
|
||||
|
|
|
|||
1
packages/docsearch-modal/askai.js
Normal file
1
packages/docsearch-modal/askai.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './dist/esm/DocSearchAskAiModal.js';
|
||||
|
|
@ -18,11 +18,13 @@
|
|||
"dist/",
|
||||
"button.js",
|
||||
"index.js",
|
||||
"modal.js"
|
||||
"modal.js",
|
||||
"askai.js"
|
||||
],
|
||||
"exports": {
|
||||
".": "./dist/esm/index.js",
|
||||
"./button": "./dist/esm/DocSearchButton.js",
|
||||
"./askai": "./dist/esm/DocSearchAskAiModal.js",
|
||||
"./modal": "./dist/esm/DocSearchModal.js"
|
||||
},
|
||||
"source": "src/index.ts",
|
||||
|
|
|
|||
45
packages/docsearch-modal/src/DocSearchAskAiModal.tsx
Normal file
45
packages/docsearch-modal/src/DocSearchAskAiModal.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { useDocSearch } from '@docsearch/core';
|
||||
import type { DocSearchAskAiModalProps as ReactDocSearchAskAiModalProps } from '@docsearch/react/askaiModal';
|
||||
import { DocSearchAskAiModal as Modal } from '@docsearch/react/askaiModal';
|
||||
import type { JSX } from 'react';
|
||||
import React from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
export type DocSearchAskAiModalProps = Omit<
|
||||
ReactDocSearchAskAiModalProps,
|
||||
| 'initialScrollY'
|
||||
| 'isAskAiActive'
|
||||
| 'isHybridModeSupported'
|
||||
| 'keyboardShortcuts'
|
||||
| 'onAskAiToggle'
|
||||
| 'onClose'
|
||||
| 'theme'
|
||||
>;
|
||||
|
||||
export function DocSearchAskAiModal(props: DocSearchAskAiModalProps): JSX.Element | null {
|
||||
const { isModalActive, onAskAiToggle, closeModal, isAskAiActive, initialQuery, registerView, isHybridModeSupported } =
|
||||
useDocSearch();
|
||||
|
||||
const containerElement = React.useMemo(() => props.portalContainer ?? document.body, [props.portalContainer]);
|
||||
|
||||
const initialScroll = React.useMemo(() => window.scrollY, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
registerView('modal');
|
||||
}, [registerView]);
|
||||
|
||||
const modalProps: ReactDocSearchAskAiModalProps = React.useMemo(
|
||||
() => ({
|
||||
...props,
|
||||
isAskAiActive,
|
||||
initialQuery: props.initialQuery ?? initialQuery,
|
||||
initialScrollY: initialScroll,
|
||||
onAskAiToggle,
|
||||
onClose: closeModal,
|
||||
isHybridModeSupported,
|
||||
}),
|
||||
[props, isAskAiActive, initialQuery, initialScroll, onAskAiToggle, closeModal, isHybridModeSupported],
|
||||
);
|
||||
|
||||
return isModalActive ? createPortal(<Modal {...modalProps} />, containerElement) : null;
|
||||
}
|
||||
|
|
@ -7,18 +7,11 @@ import { createPortal } from 'react-dom';
|
|||
|
||||
export type DocSearchModalProps = Omit<
|
||||
ReactDocSearchModalProps,
|
||||
| 'initialScrollY'
|
||||
| 'isAskAiActive'
|
||||
| 'isHybridModeSupported'
|
||||
| 'keyboardShortcuts'
|
||||
| 'onAskAiToggle'
|
||||
| 'onClose'
|
||||
| 'theme'
|
||||
'initialScrollY' | 'keyboardShortcuts' | 'onClose' | 'theme'
|
||||
>;
|
||||
|
||||
export function DocSearchModal(props: DocSearchModalProps): JSX.Element | null {
|
||||
const { isModalActive, onAskAiToggle, closeModal, isAskAiActive, initialQuery, registerView, isHybridModeSupported } =
|
||||
useDocSearch();
|
||||
const { isModalActive, closeModal, initialQuery, registerView } = useDocSearch();
|
||||
|
||||
const containerElement = React.useMemo(() => props.portalContainer ?? document.body, [props.portalContainer]);
|
||||
|
||||
|
|
@ -31,14 +24,11 @@ export function DocSearchModal(props: DocSearchModalProps): JSX.Element | null {
|
|||
const modalProps: ReactDocSearchModalProps = React.useMemo(
|
||||
() => ({
|
||||
...props,
|
||||
isAskAiActive,
|
||||
initialQuery: props.initialQuery ?? initialQuery,
|
||||
initialScrollY: initialScroll,
|
||||
onAskAiToggle,
|
||||
onClose: closeModal,
|
||||
isHybridModeSupported,
|
||||
}),
|
||||
[props, isAskAiActive, initialQuery, initialScroll, onAskAiToggle, closeModal, isHybridModeSupported],
|
||||
[props, initialQuery, initialScroll, closeModal],
|
||||
);
|
||||
|
||||
return isModalActive ? createPortal(<Modal {...modalProps} />, containerElement) : null;
|
||||
|
|
|
|||
|
|
@ -1,2 +1,3 @@
|
|||
export * from './DocSearchButton';
|
||||
export * from './DocSearchAskAiModal';
|
||||
export * from './DocSearchModal';
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ export default defineConfig([
|
|||
entry: {
|
||||
index: 'src/index.ts',
|
||||
DocSearchButton: 'src/DocSearchButton.tsx',
|
||||
DocSearchAskAiModal: 'src/DocSearchAskAiModal.tsx',
|
||||
DocSearchModal: 'src/DocSearchModal.tsx',
|
||||
},
|
||||
outDir: 'dist/esm',
|
||||
|
|
|
|||
1
packages/docsearch-react/askaiModal.js
Normal file
1
packages/docsearch-react/askaiModal.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { DocSearchAskAiModal } from './dist/esm/DocSearchAskAiModal.js';
|
||||
1
packages/docsearch-react/docsearchAi.js
Normal file
1
packages/docsearch-react/docsearchAi.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { DocSearchAI } from './dist/esm/DocSearchAI.js';
|
||||
|
|
@ -16,6 +16,8 @@
|
|||
"sideEffects": false,
|
||||
"files": [
|
||||
"dist/",
|
||||
"askaiModal.js",
|
||||
"docsearchAi.js",
|
||||
"button.js",
|
||||
"modal.js",
|
||||
"useTheme.js",
|
||||
|
|
@ -26,6 +28,8 @@
|
|||
],
|
||||
"exports": {
|
||||
".": "./dist/esm/index.js",
|
||||
"./askaiModal": "./dist/esm/DocSearchAskAiModal.js",
|
||||
"./docsearchAi": "./dist/esm/DocSearchAI.js",
|
||||
"./button": "./dist/esm/DocSearchButton.js",
|
||||
"./modal": "./dist/esm/DocSearchModal.js",
|
||||
"./style": "./style/index.js",
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@ import type { UseChatHelpers } from '@ai-sdk/react';
|
|||
import React, { type JSX, useMemo, useState, useEffect } from 'react';
|
||||
|
||||
import { AggregatedSearchBlock } from './AggregatedSearchBlock';
|
||||
import type { AskAiScreenStateProps } from './AskAiScreenState';
|
||||
import { AlertIcon, LoadingIcon } from './icons';
|
||||
import { MemoizedMarkdown } from './MemoizedMarkdown';
|
||||
import type { ScreenStateProps } from './ScreenState';
|
||||
import type { StoredSearchPlugin } from './stored-searches';
|
||||
import { ToolCall } from './ToolCall';
|
||||
import type { InternalDocSearchHit, StoredAskAiState } from './types';
|
||||
|
|
@ -63,7 +63,7 @@ export type AskAiScreenTranslations = Partial<{
|
|||
startNewConversationButtonText: string;
|
||||
}>;
|
||||
|
||||
type AskAiScreenProps = Omit<ScreenStateProps<InternalDocSearchHit>, 'translations'> & {
|
||||
type AskAiScreenProps = Omit<AskAiScreenStateProps<InternalDocSearchHit>, 'translations'> & {
|
||||
messages: AIMessage[];
|
||||
status: UseChatHelpers<AIMessage>['status'];
|
||||
askAiError?: Error;
|
||||
|
|
|
|||
122
packages/docsearch-react/src/AskAiScreenState.tsx
Normal file
122
packages/docsearch-react/src/AskAiScreenState.tsx
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import type { UseChatHelpers } from '@ai-sdk/react';
|
||||
import type { AutocompleteApi, AutocompleteState, BaseItem } from '@algolia/autocomplete-core';
|
||||
import React from 'react';
|
||||
|
||||
import type { AskAiScreenTranslations } from './AskAiScreen';
|
||||
import { AskAiScreen } from './AskAiScreen';
|
||||
import type { AskAiStartScreenTranslations } from './components/AskAiStartScreen';
|
||||
import { AskAiStartScreen } from './components/AskAiStartScreen';
|
||||
import { ConversationHistoryScreen } from './ConversationHistoryScreen';
|
||||
import type { DocSearchProps } from './DocSearch';
|
||||
import type { ErrorScreenTranslations } from './ErrorScreen';
|
||||
import { ErrorScreen } from './ErrorScreen';
|
||||
import type { NewConversationTranslations } from './NewConversationScreen';
|
||||
import { NewConversationScreen } from './NewConversationScreen';
|
||||
import type { NoResultsScreenTranslations } from './NoResultsScreen';
|
||||
import { NoResultsScreen } from './NoResultsScreen';
|
||||
import type { ResultsScreenTranslations } from './ResultsScreen';
|
||||
import { ResultsScreen } from './ResultsScreen';
|
||||
import type { StoredSearchPlugin } from './stored-searches';
|
||||
import type { InternalDocSearchHit, StoredAskAiState, StoredDocSearchHit, SuggestedQuestionHit } from './types';
|
||||
import type { AIMessage, AskAiState } from './types/AskiAi';
|
||||
|
||||
export type AskAiScreenStateTranslations = Partial<{
|
||||
errorScreen: ErrorScreenTranslations;
|
||||
startScreen: AskAiStartScreenTranslations;
|
||||
noResultsScreen: NoResultsScreenTranslations;
|
||||
resultsScreen: ResultsScreenTranslations;
|
||||
askAiScreen: AskAiScreenTranslations;
|
||||
newConversation: NewConversationTranslations;
|
||||
}>;
|
||||
|
||||
export interface AskAiScreenStateProps<TItem extends BaseItem>
|
||||
extends AutocompleteApi<TItem, React.FormEvent, React.MouseEvent, React.KeyboardEvent> {
|
||||
state: AutocompleteState<TItem>;
|
||||
recentSearches: StoredSearchPlugin<StoredDocSearchHit>;
|
||||
favoriteSearches: StoredSearchPlugin<StoredDocSearchHit>;
|
||||
conversations: StoredSearchPlugin<StoredAskAiState>;
|
||||
onItemClick: (item: InternalDocSearchHit, event: KeyboardEvent | MouseEvent) => void;
|
||||
onAskAiToggle: (toggle: boolean) => void;
|
||||
isAskAiActive: boolean;
|
||||
canHandleAskAi: boolean;
|
||||
inputRef: React.MutableRefObject<HTMLInputElement | null>;
|
||||
hitComponent: DocSearchProps['hitComponent'];
|
||||
indexName: DocSearchProps['indexName'];
|
||||
messages: UseChatHelpers<AIMessage>['messages'];
|
||||
status: UseChatHelpers<AIMessage>['status'];
|
||||
askAiError?: Error;
|
||||
disableUserPersonalization: boolean;
|
||||
resultsFooterComponent: DocSearchProps['resultsFooterComponent'];
|
||||
translations: AskAiScreenStateTranslations;
|
||||
getMissingResultsUrl?: DocSearchProps['getMissingResultsUrl'];
|
||||
hasCollections: boolean;
|
||||
onFeedback?: (messageId: string, thumbs: 0 | 1) => Promise<void>;
|
||||
askAiState: AskAiState;
|
||||
selectAskAiQuestion: (toggle: boolean, query: string) => void;
|
||||
suggestedQuestions: SuggestedQuestionHit[];
|
||||
selectSuggestedQuestion: (question: SuggestedQuestionHit) => void;
|
||||
onNewConversation: () => void;
|
||||
agentStudio?: boolean;
|
||||
}
|
||||
|
||||
export const AskAiScreenState = React.memo(
|
||||
({ translations = {}, ...props }: AskAiScreenStateProps<InternalDocSearchHit>) => {
|
||||
if (props.canHandleAskAi && props.isAskAiActive && props.askAiState === 'conversation-history') {
|
||||
return <ConversationHistoryScreen {...props} />;
|
||||
}
|
||||
|
||||
if (props.canHandleAskAi && props.isAskAiActive && props.askAiState === 'new-conversation') {
|
||||
return (
|
||||
<NewConversationScreen
|
||||
translations={translations?.newConversation}
|
||||
selectSuggestedQuestion={props.selectSuggestedQuestion}
|
||||
suggestedQuestions={props.suggestedQuestions}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (props.isAskAiActive && props.canHandleAskAi) {
|
||||
return (
|
||||
<AskAiScreen
|
||||
{...props}
|
||||
messages={props.messages}
|
||||
status={props.status}
|
||||
askAiError={props.askAiError}
|
||||
translations={translations?.askAiScreen}
|
||||
agentStudio={props.agentStudio}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (props.state?.status === 'error') {
|
||||
return <ErrorScreen translations={translations?.errorScreen} />;
|
||||
}
|
||||
|
||||
if (!props.state.query) {
|
||||
return (
|
||||
<AskAiStartScreen {...props} hasCollections={props.hasCollections} translations={translations?.startScreen} />
|
||||
);
|
||||
}
|
||||
|
||||
if (!props.hasCollections && !props.canHandleAskAi) {
|
||||
return <NoResultsScreen {...props} translations={translations?.noResultsScreen} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ResultsScreen {...props} translations={translations?.resultsScreen} />
|
||||
{props.canHandleAskAi && props.state.collections.length === 1 && (
|
||||
// if there's one collection it is the ask ai action, show the no results screen
|
||||
<NoResultsScreen {...props} translations={translations?.noResultsScreen} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
function areEqual(_prevProps, nextProps) {
|
||||
// We don't update the screen when Autocomplete is loading or stalled to
|
||||
// avoid UI flashes:
|
||||
// - Empty screen → Results screen
|
||||
// - NoResults screen → NoResults screen with another query
|
||||
return nextProps.state.status === 'loading' || nextProps.state.status === 'stalled';
|
||||
},
|
||||
);
|
||||
|
|
@ -1,13 +1,13 @@
|
|||
import type { JSX } from 'react';
|
||||
import React from 'react';
|
||||
|
||||
import type { AskAiScreenStateProps } from './AskAiScreenState';
|
||||
import { CloseIcon, SparklesIcon } from './icons';
|
||||
import { Results } from './Results';
|
||||
import type { ResultsScreenTranslations } from './ResultsScreen';
|
||||
import type { ScreenStateProps } from './ScreenState';
|
||||
import type { InternalDocSearchHit } from './types';
|
||||
|
||||
type ConversationHistoryScreenProps = Omit<ScreenStateProps<InternalDocSearchHit>, 'translations'> & {
|
||||
type ConversationHistoryScreenProps = Omit<AskAiScreenStateProps<InternalDocSearchHit>, 'translations'> & {
|
||||
translations?: ResultsScreenTranslations;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -6,11 +6,11 @@ import React, { type JSX } from 'react';
|
|||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { DocSearchButton } from './DocSearchButton';
|
||||
import type { ButtonTranslations } from './DocSearchButton';
|
||||
import { DocSearchModal } from './DocSearchModal';
|
||||
import type { ModalTranslations } from './DocSearchModal';
|
||||
import type { DocSearchHit, DocSearchTheme, InternalDocSearchHit, StoredDocSearchHit } from './types';
|
||||
|
||||
import type { ButtonTranslations, ModalTranslations } from '.';
|
||||
|
||||
export type { DocSearchRef } from '@docsearch/core';
|
||||
|
||||
export type DocSearchTranslations = Partial<{
|
||||
|
|
@ -128,17 +128,6 @@ export interface DocSearchProps {
|
|||
* @see {@link https://docsearch.algolia.com/docs/api#indices}
|
||||
*/
|
||||
indices?: Array<DocSearchIndex | string>;
|
||||
/**
|
||||
* Configuration or assistant id to enable ask ai mode. Pass a string assistant id or a full config object.
|
||||
*/
|
||||
askAi?: DocSearchAskAi | string;
|
||||
/**
|
||||
* Intercept Ask AI requests (e.g. Submitting a prompt or selecting a suggested question).
|
||||
*
|
||||
* Return `true` to prevent the default modal Ask AI flow (no toggle, no sendMessage).
|
||||
* Useful to route Ask AI into a different UI (e.g. `@docsearch/sidepanel-js`) without flicker.
|
||||
*/
|
||||
interceptAskAiEvent?: (initialMessage: InitialAskAiMessage) => boolean | void;
|
||||
/**
|
||||
* Theme overrides applied to the modal and related components.
|
||||
*/
|
||||
|
|
@ -244,6 +233,20 @@ export interface DocSearchProps {
|
|||
keyboardShortcuts?: DocSearchModalShortcuts;
|
||||
}
|
||||
|
||||
export interface DocSearchAIProps extends DocSearchProps {
|
||||
/**
|
||||
* Configuration or assistant id to enable ask ai mode. Pass a string assistant id or a full config object.
|
||||
*/
|
||||
askAi: DocSearchAskAi | string;
|
||||
/**
|
||||
* Intercept Ask AI requests (e.g. Submitting a prompt or selecting a suggested question).
|
||||
*
|
||||
* Return `true` to prevent the default modal Ask AI flow (no toggle, no sendMessage).
|
||||
* Useful to route Ask AI into a different UI (e.g. `@docsearch/sidepanel-js`) without flicker.
|
||||
*/
|
||||
interceptAskAiEvent?: (initialMessage: InitialAskAiMessage) => boolean | void;
|
||||
}
|
||||
|
||||
function DocSearchComponent(props: DocSearchProps, ref: React.ForwardedRef<DocSearchRef>): JSX.Element {
|
||||
return (
|
||||
<DocSearchProvider {...props} ref={ref}>
|
||||
|
|
@ -255,16 +258,7 @@ function DocSearchComponent(props: DocSearchProps, ref: React.ForwardedRef<DocSe
|
|||
export const DocSearch = React.forwardRef(DocSearchComponent);
|
||||
|
||||
export function DocSearchInner(props: DocSearchProps): JSX.Element {
|
||||
const {
|
||||
searchButtonRef,
|
||||
keyboardShortcuts,
|
||||
isModalActive,
|
||||
isAskAiActive,
|
||||
initialQuery,
|
||||
onAskAiToggle,
|
||||
openModal,
|
||||
closeModal,
|
||||
} = useDocSearch();
|
||||
const { searchButtonRef, keyboardShortcuts, isModalActive, initialQuery, openModal, closeModal } = useDocSearch();
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -281,8 +275,6 @@ export function DocSearchInner(props: DocSearchProps): JSX.Element {
|
|||
initialScrollY={window.scrollY}
|
||||
initialQuery={initialQuery}
|
||||
translations={props?.translations?.modal}
|
||||
isAskAiActive={isAskAiActive}
|
||||
onAskAiToggle={onAskAiToggle}
|
||||
onClose={closeModal}
|
||||
/>,
|
||||
props.portalContainer ?? document.body,
|
||||
|
|
|
|||
57
packages/docsearch-react/src/DocSearchAI.tsx
Normal file
57
packages/docsearch-react/src/DocSearchAI.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { DocSearch as DocSearchProvider, useDocSearch } from '@docsearch/core';
|
||||
import type { DocSearchRef } from '@docsearch/core';
|
||||
import React, { type JSX } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import type { DocSearchAIProps } from './DocSearch';
|
||||
import { DocSearchAskAiModal } from './DocSearchAskAiModal';
|
||||
import { DocSearchButton } from './DocSearchButton';
|
||||
|
||||
function DocSearchAIComponent(props: DocSearchAIProps, ref: React.ForwardedRef<DocSearchRef>): JSX.Element {
|
||||
return (
|
||||
<DocSearchProvider {...props} ref={ref}>
|
||||
<DocSearchAIInner {...props} />
|
||||
</DocSearchProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export const DocSearchAI = React.forwardRef(DocSearchAIComponent);
|
||||
|
||||
export function DocSearchAIInner(props: DocSearchAIProps): JSX.Element {
|
||||
const {
|
||||
searchButtonRef,
|
||||
keyboardShortcuts,
|
||||
isModalActive,
|
||||
isAskAiActive,
|
||||
initialQuery,
|
||||
onAskAiToggle,
|
||||
openModal,
|
||||
closeModal,
|
||||
isHybridModeSupported,
|
||||
} = useDocSearch();
|
||||
|
||||
return (
|
||||
<>
|
||||
<DocSearchButton
|
||||
keyboardShortcuts={keyboardShortcuts}
|
||||
ref={searchButtonRef}
|
||||
translations={props.translations?.button}
|
||||
onClick={openModal}
|
||||
/>
|
||||
{isModalActive &&
|
||||
createPortal(
|
||||
<DocSearchAskAiModal
|
||||
{...props}
|
||||
initialScrollY={window.scrollY}
|
||||
initialQuery={initialQuery}
|
||||
translations={props?.translations?.modal}
|
||||
isAskAiActive={isAskAiActive}
|
||||
isHybridModeSupported={isHybridModeSupported}
|
||||
onAskAiToggle={onAskAiToggle}
|
||||
onClose={closeModal}
|
||||
/>,
|
||||
props.portalContainer ?? document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
505
packages/docsearch-react/src/DocSearchAskAiModal.tsx
Normal file
505
packages/docsearch-react/src/DocSearchAskAiModal.tsx
Normal file
|
|
@ -0,0 +1,505 @@
|
|||
import { createAutocomplete } from '@algolia/autocomplete-core';
|
||||
import type { InitialAskAiMessage, OnAskAiToggle } from '@docsearch/core';
|
||||
import type { ChatRequestOptions } from 'ai';
|
||||
import React, { type JSX } from 'react';
|
||||
|
||||
import type { AskAiScreenStateTranslations } from './AskAiScreenState';
|
||||
import { AskAiScreenState } from './AskAiScreenState';
|
||||
import type { AskAiSearchBoxTranslations } from './components/AskAiSearchBox';
|
||||
import { AskAiSearchBox } from './components/AskAiSearchBox';
|
||||
import { ModalShell } from './components/ui/ModalShell';
|
||||
import type { DocSearchAIProps } from './DocSearch';
|
||||
import type { FooterTranslations } from './Footer';
|
||||
import { Footer } from './Footer';
|
||||
import { Hit } from './Hit';
|
||||
import { useSendItemClickEvent } from './hooks/useDocSearchInsights';
|
||||
import { useInitialModalQuery } from './hooks/useInitialModalQuery';
|
||||
import { useModalEnvironment } from './hooks/useModalEnvironment';
|
||||
import { useModalRefs } from './hooks/useModalRefs';
|
||||
import { useRefreshOnInitialQuery } from './hooks/useRefreshOnInitialQuery';
|
||||
import { useSaveRecentSearch } from './hooks/useSaveRecentSearch';
|
||||
import { useStoredDocSearches } from './hooks/useStoredDocSearches';
|
||||
import type { NewConversationTranslations } from './NewConversationScreen';
|
||||
import type { DocSearchState, InternalDocSearchHit, StoredAskAiMessage, SuggestedQuestionHit } from './types';
|
||||
import type { AskAiState } from './types/AskiAi';
|
||||
import { useAskAi } from './useAskAi';
|
||||
import { useSearchClient } from './useSearchClient';
|
||||
import { useSuggestedQuestions } from './useSuggestedQuestions';
|
||||
import { identity, isModifierEvent, noop, scrollTo as scrollToUtils } from './utils';
|
||||
import { buildDummyAskAiHit, isThreadDepthError } from './utils/ai';
|
||||
import { buildAskAiActionSources, buildRecentConversationSources } from './utils/createAskAiSources';
|
||||
import { buildNoQuerySources, buildQuerySources, type BuildQuerySourcesState } from './utils/createDocSearchSources';
|
||||
import { normalizeDocSearchIndexes } from './utils/normalizeDocSearchIndexes';
|
||||
|
||||
export type DocSearchAskAiModalTranslations = AskAiScreenStateTranslations &
|
||||
Partial<{
|
||||
searchBox: AskAiSearchBoxTranslations;
|
||||
newConversation: NewConversationTranslations;
|
||||
footer: FooterTranslations;
|
||||
}>;
|
||||
|
||||
export type DocSearchAskAiModalProps = DocSearchAIProps & {
|
||||
initialScrollY: number;
|
||||
onAskAiToggle: OnAskAiToggle;
|
||||
onClose?: () => void;
|
||||
isAskAiActive?: boolean;
|
||||
translations?: DocSearchAskAiModalTranslations;
|
||||
isHybridModeSupported?: boolean;
|
||||
};
|
||||
|
||||
export function DocSearchAskAiModal({
|
||||
appId,
|
||||
apiKey,
|
||||
askAi,
|
||||
maxResultsPerGroup,
|
||||
theme,
|
||||
onClose = noop,
|
||||
transformItems = identity,
|
||||
hitComponent = Hit,
|
||||
resultsFooterComponent = (): JSX.Element | null => null,
|
||||
navigator,
|
||||
initialScrollY = 0,
|
||||
transformSearchClient = identity,
|
||||
disableUserPersonalization = false,
|
||||
initialQuery: initialQueryFromProp = '',
|
||||
translations = {},
|
||||
getMissingResultsUrl,
|
||||
insights = false,
|
||||
onAskAiToggle,
|
||||
interceptAskAiEvent,
|
||||
isAskAiActive = false,
|
||||
recentSearchesLimit = 7,
|
||||
recentSearchesWithFavoritesLimit = 4,
|
||||
indices = [],
|
||||
indexName,
|
||||
searchParameters,
|
||||
isHybridModeSupported = false,
|
||||
...props
|
||||
}: DocSearchAskAiModalProps): JSX.Element {
|
||||
const { footer: footerTranslations, searchBox: searchBoxTranslations, ...screenStateTranslations } = translations;
|
||||
const [state, setState] = React.useState<DocSearchState<InternalDocSearchHit>>({
|
||||
query: '',
|
||||
collections: [],
|
||||
completion: null,
|
||||
context: {},
|
||||
isOpen: false,
|
||||
activeItemId: null,
|
||||
status: 'idle',
|
||||
});
|
||||
|
||||
// check if the instance is configured to handle ask ai
|
||||
const canHandleAskAi = Boolean(askAi);
|
||||
|
||||
let placeholder = translations?.searchBox?.placeholderText || props.placeholder || 'Search docs';
|
||||
|
||||
if (canHandleAskAi) {
|
||||
placeholder = translations?.searchBox?.placeholderText || 'Search docs or ask AI a question';
|
||||
}
|
||||
|
||||
if (isAskAiActive) {
|
||||
placeholder = translations?.searchBox?.placeholderTextAskAi || 'Ask another question...';
|
||||
}
|
||||
|
||||
const { containerRef, modalRef, formElementRef, dropdownRef, inputRef, snippetLength } = useModalRefs();
|
||||
const { initialQuery, initialQueryFromSelection } = useInitialModalQuery(initialQueryFromProp);
|
||||
|
||||
const searchClient = useSearchClient(appId, apiKey, transformSearchClient);
|
||||
|
||||
const askAiConfig = typeof askAi === 'object' ? askAi : null;
|
||||
const askAiConfigurationId = typeof askAi === 'string' ? askAi : askAiConfig?.assistantId || null;
|
||||
const askAiSearchParameters = askAiConfig?.searchParameters;
|
||||
const askAiUseStagingEnv = askAiConfig?.useStagingEnv || false;
|
||||
const [askAiState, setAskAiState] = React.useState<AskAiState>('initial');
|
||||
const suggestedQuestions = useSuggestedQuestions({
|
||||
assistantId: askAiConfigurationId,
|
||||
searchClient,
|
||||
suggestedQuestionsEnabled: askAiConfig?.suggestedQuestions,
|
||||
});
|
||||
const agentStudio = askAiConfig?.agentStudio ?? false;
|
||||
|
||||
const indexes = normalizeDocSearchIndexes({
|
||||
indexName,
|
||||
indices,
|
||||
searchParameters,
|
||||
});
|
||||
const defaultIndexName = indexes[0].name;
|
||||
|
||||
const { favoriteSearches, recentSearches } = useStoredDocSearches({
|
||||
defaultIndexName,
|
||||
recentSearchesLimit,
|
||||
recentSearchesWithFavoritesLimit,
|
||||
});
|
||||
|
||||
const [stoppedStream, setStoppedStream] = React.useState(false);
|
||||
|
||||
const { messages, status, setMessages, sendMessage, stopAskAiStreaming, askAiError, sendFeedback, conversations } =
|
||||
useAskAi({
|
||||
assistantId: askAiConfigurationId,
|
||||
apiKey: askAiConfig?.apiKey || apiKey,
|
||||
appId: askAiConfig?.appId || appId,
|
||||
indexName: askAiConfig?.indexName || defaultIndexName,
|
||||
searchParameters: askAiSearchParameters,
|
||||
useStagingEnv: askAiUseStagingEnv,
|
||||
agentStudio,
|
||||
});
|
||||
|
||||
const prevStatus = React.useRef(status);
|
||||
React.useEffect(() => {
|
||||
if (disableUserPersonalization) {
|
||||
return;
|
||||
}
|
||||
// if we just transitioned from "streaming" → "ready", persist
|
||||
if (prevStatus.current === 'streaming' && status === 'ready') {
|
||||
// if we stopped the stream, store it on the most recent message
|
||||
if (stoppedStream && messages.at(-1)) {
|
||||
messages.at(-1)!.metadata = {
|
||||
stopped: true,
|
||||
};
|
||||
}
|
||||
|
||||
for (const part of messages[0].parts) {
|
||||
if (part.type === 'text') {
|
||||
conversations.add(buildDummyAskAiHit(part.text, messages));
|
||||
}
|
||||
}
|
||||
}
|
||||
prevStatus.current = status;
|
||||
}, [status, messages, conversations, disableUserPersonalization, stoppedStream]);
|
||||
|
||||
// Check if there's a thread depth error (AI-217)
|
||||
const hasThreadDepthError = React.useMemo(() => {
|
||||
return status === 'error' && isThreadDepthError(askAiError as Error | undefined);
|
||||
}, [status, askAiError]);
|
||||
|
||||
const saveRecentSearch = useSaveRecentSearch({
|
||||
favoriteSearches,
|
||||
recentSearches,
|
||||
disableUserPersonalization,
|
||||
});
|
||||
const sendItemClickEvent = useSendItemClickEvent(state);
|
||||
|
||||
const autocompleteRef =
|
||||
React.useRef<
|
||||
ReturnType<
|
||||
typeof createAutocomplete<
|
||||
InternalDocSearchHit,
|
||||
React.FormEvent<HTMLFormElement>,
|
||||
React.MouseEvent,
|
||||
React.KeyboardEvent
|
||||
>
|
||||
>
|
||||
>(undefined);
|
||||
|
||||
const handleSelectAskAiQuestion = React.useCallback(
|
||||
(toggle: boolean, query: string, suggestedQuestion: SuggestedQuestionHit | undefined = undefined) => {
|
||||
if (toggle) {
|
||||
const initialMessage: InitialAskAiMessage = {
|
||||
query,
|
||||
suggestedQuestionId: suggestedQuestion?.objectID,
|
||||
};
|
||||
|
||||
if (interceptAskAiEvent?.(initialMessage)) {
|
||||
// Consumer handled it. Avoid *all* default Ask AI behavior.
|
||||
if (autocompleteRef.current) {
|
||||
autocompleteRef.current.setQuery('');
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (toggle && askAiState === 'new-conversation') {
|
||||
setAskAiState('initial');
|
||||
}
|
||||
|
||||
onAskAiToggle(toggle, {
|
||||
query,
|
||||
suggestedQuestionId: suggestedQuestion?.objectID,
|
||||
});
|
||||
|
||||
// If we're in hybrid mode, we don't need to send the message,
|
||||
// it will be handled by the Sidepanel.
|
||||
if (isHybridModeSupported) return;
|
||||
|
||||
setStoppedStream(false);
|
||||
|
||||
const messageOptions: ChatRequestOptions = {};
|
||||
|
||||
if (suggestedQuestion) {
|
||||
messageOptions.body = {
|
||||
suggestedQuestionId: suggestedQuestion.objectID,
|
||||
};
|
||||
}
|
||||
|
||||
sendMessage(
|
||||
{
|
||||
role: 'user',
|
||||
parts: [
|
||||
{
|
||||
type: 'text',
|
||||
text: query,
|
||||
},
|
||||
],
|
||||
},
|
||||
messageOptions,
|
||||
);
|
||||
|
||||
if (dropdownRef.current) {
|
||||
// some test environments (like jsdom) don't implement element.scrollTo
|
||||
const el = dropdownRef.current;
|
||||
if (typeof (el as any).scrollTo === 'function') {
|
||||
el.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
} else {
|
||||
// fallback for environments without scrollTo support
|
||||
el.scrollTop = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// clear the query
|
||||
if (autocompleteRef.current) {
|
||||
autocompleteRef.current.setQuery('');
|
||||
}
|
||||
},
|
||||
[askAiState, onAskAiToggle, isHybridModeSupported, sendMessage, dropdownRef, interceptAskAiEvent],
|
||||
);
|
||||
|
||||
// feedback handler
|
||||
const handleFeedbackSubmit = React.useCallback(
|
||||
async (messageId: string, thumbs: 0 | 1): Promise<void> => {
|
||||
if (!askAiConfigurationId || !appId) return;
|
||||
await sendFeedback(messageId, thumbs);
|
||||
},
|
||||
[askAiConfigurationId, appId, sendFeedback],
|
||||
);
|
||||
|
||||
if (!autocompleteRef.current) {
|
||||
autocompleteRef.current = createAutocomplete({
|
||||
id: 'docsearch',
|
||||
defaultActiveItemId: 0,
|
||||
openOnFocus: true,
|
||||
initialState: {
|
||||
query: initialQuery,
|
||||
context: {
|
||||
searchSuggestions: [],
|
||||
},
|
||||
},
|
||||
insights: Boolean(insights),
|
||||
navigator,
|
||||
onStateChange(changes) {
|
||||
setState(changes.state);
|
||||
},
|
||||
getSources({ query, state: sourcesState, setContext, setStatus }) {
|
||||
if (!query) {
|
||||
const noQuerySources = buildNoQuerySources({
|
||||
recentSearches,
|
||||
favoriteSearches,
|
||||
saveRecentSearch,
|
||||
onClose,
|
||||
disableUserPersonalization,
|
||||
});
|
||||
|
||||
const recentConversationSource = canHandleAskAi
|
||||
? buildRecentConversationSources({
|
||||
conversations,
|
||||
disableUserPersonalization,
|
||||
setMessages,
|
||||
onAskAiToggle,
|
||||
})
|
||||
: [];
|
||||
return [...noQuerySources, ...recentConversationSource];
|
||||
}
|
||||
|
||||
const querySourcesState: BuildQuerySourcesState = {
|
||||
context: sourcesState.context,
|
||||
};
|
||||
|
||||
const algoliaSourcesPromise = buildQuerySources({
|
||||
query,
|
||||
state: querySourcesState,
|
||||
setContext,
|
||||
setStatus,
|
||||
searchClient,
|
||||
indexes,
|
||||
snippetLength,
|
||||
insights: Boolean(insights),
|
||||
appId,
|
||||
apiKey,
|
||||
maxResultsPerGroup,
|
||||
transformItems,
|
||||
saveRecentSearch,
|
||||
onClose,
|
||||
});
|
||||
|
||||
const askAiSource = canHandleAskAi ? buildAskAiActionSources({ query, handleSelectAskAiQuestion }) : [];
|
||||
// Combine Algolia results (once resolved) with the Ask AI source
|
||||
return algoliaSourcesPromise.then((algoliaSources) => {
|
||||
return [...askAiSource, ...algoliaSources];
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const autocomplete = autocompleteRef.current;
|
||||
|
||||
const { getEnvironmentProps, getRootProps, refresh } = autocomplete;
|
||||
|
||||
useModalEnvironment({
|
||||
getEnvironmentProps,
|
||||
containerRef,
|
||||
dropdownRef,
|
||||
formElementRef,
|
||||
inputRef,
|
||||
initialScrollY,
|
||||
modalRef,
|
||||
snippetLength,
|
||||
theme,
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (dropdownRef.current && !isAskAiActive) {
|
||||
scrollToUtils(dropdownRef.current);
|
||||
}
|
||||
}, [state.query, isAskAiActive, dropdownRef]);
|
||||
|
||||
useRefreshOnInitialQuery({ initialQuery, inputRef, refresh });
|
||||
|
||||
// Refresh the autocomplete results when ask ai is toggled off
|
||||
// helps return to the previous ac state and start screen
|
||||
React.useEffect(() => {
|
||||
if (!isAskAiActive) {
|
||||
autocomplete.refresh();
|
||||
setMessages([]);
|
||||
}
|
||||
}, [isAskAiActive, autocomplete, setMessages]);
|
||||
|
||||
// Track external state in order to manage internal askAiState
|
||||
React.useEffect(() => {
|
||||
setAskAiState('initial');
|
||||
}, [isAskAiActive, setAskAiState]);
|
||||
|
||||
const onStopAskAiStreaming = async (): Promise<void> => {
|
||||
setStoppedStream(true);
|
||||
|
||||
await stopAskAiStreaming();
|
||||
};
|
||||
|
||||
const handleNewConversation = (): void => {
|
||||
setMessages([]);
|
||||
setAskAiState('new-conversation');
|
||||
};
|
||||
|
||||
const handleViewConversationHistory = (): void => {
|
||||
setAskAiState('conversation-history');
|
||||
};
|
||||
|
||||
const selectSuggestedQuestion = (suggestedQuestion: SuggestedQuestionHit): void => {
|
||||
handleSelectAskAiQuestion(true, suggestedQuestion.question, suggestedQuestion);
|
||||
};
|
||||
|
||||
// hide the dropdown on idle and no collections
|
||||
let showDocsearchDropdown = true;
|
||||
const hasCollections = state.collections.some((collection) => collection.items.length > 0);
|
||||
if (state.status === 'idle' && hasCollections === false && state.query.length === 0 && !isAskAiActive) {
|
||||
showDocsearchDropdown = false;
|
||||
}
|
||||
|
||||
return (
|
||||
<ModalShell
|
||||
state={state}
|
||||
containerRef={containerRef}
|
||||
modalRef={modalRef}
|
||||
formElementRef={formElementRef}
|
||||
dropdownRef={dropdownRef}
|
||||
getRootProps={getRootProps}
|
||||
showDropdown={showDocsearchDropdown}
|
||||
searchBox={
|
||||
<AskAiSearchBox
|
||||
{...autocomplete}
|
||||
state={state}
|
||||
placeholder={placeholder || 'Search docs'}
|
||||
autoFocus={initialQuery.length === 0}
|
||||
inputRef={inputRef}
|
||||
isFromSelection={Boolean(initialQuery) && initialQuery === initialQueryFromSelection}
|
||||
translations={searchBoxTranslations}
|
||||
isAskAiActive={isAskAiActive}
|
||||
askAiStatus={status}
|
||||
askAiError={askAiError}
|
||||
askAiState={askAiState}
|
||||
setAskAiState={setAskAiState}
|
||||
isThreadDepthError={hasThreadDepthError && askAiState !== 'new-conversation'}
|
||||
onClose={onClose}
|
||||
onAskAiToggle={onAskAiToggle}
|
||||
onAskAgain={(query) => {
|
||||
handleSelectAskAiQuestion(true, query);
|
||||
}}
|
||||
onStopAskAiStreaming={onStopAskAiStreaming}
|
||||
onNewConversation={handleNewConversation}
|
||||
onViewConversationHistory={handleViewConversationHistory}
|
||||
/>
|
||||
}
|
||||
screenState={
|
||||
<AskAiScreenState
|
||||
{...autocomplete}
|
||||
indexName={defaultIndexName}
|
||||
state={state}
|
||||
hitComponent={hitComponent}
|
||||
resultsFooterComponent={resultsFooterComponent}
|
||||
disableUserPersonalization={disableUserPersonalization}
|
||||
recentSearches={recentSearches}
|
||||
favoriteSearches={favoriteSearches}
|
||||
conversations={conversations}
|
||||
inputRef={inputRef}
|
||||
translations={screenStateTranslations}
|
||||
getMissingResultsUrl={getMissingResultsUrl}
|
||||
isAskAiActive={isAskAiActive}
|
||||
canHandleAskAi={canHandleAskAi}
|
||||
messages={messages}
|
||||
askAiError={askAiError}
|
||||
status={status}
|
||||
hasCollections={hasCollections}
|
||||
askAiState={askAiState}
|
||||
selectAskAiQuestion={handleSelectAskAiQuestion}
|
||||
suggestedQuestions={suggestedQuestions}
|
||||
selectSuggestedQuestion={selectSuggestedQuestion}
|
||||
agentStudio={agentStudio}
|
||||
onAskAiToggle={onAskAiToggle}
|
||||
onNewConversation={handleNewConversation}
|
||||
onItemClick={(item, event) => {
|
||||
if (item.type === 'askAI' && item.query) {
|
||||
if (item.anchor === 'stored' && 'messages' in item) {
|
||||
setMessages(item.messages as any);
|
||||
const initialMessage: InitialAskAiMessage = {
|
||||
query: item.query,
|
||||
messageId: (item.messages as StoredAskAiMessage[])[0].id,
|
||||
};
|
||||
|
||||
if (interceptAskAiEvent?.(initialMessage)) {
|
||||
if (autocompleteRef.current) {
|
||||
autocompleteRef.current.setQuery('');
|
||||
}
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
onAskAiToggle(true, initialMessage);
|
||||
} else {
|
||||
handleSelectAskAiQuestion(true, item.query);
|
||||
}
|
||||
setAskAiState('initial');
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
sendItemClickEvent(item);
|
||||
saveRecentSearch(item);
|
||||
if (!isModifierEvent(event)) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
onFeedback={handleFeedbackSubmit}
|
||||
/>
|
||||
}
|
||||
footer={<Footer translations={footerTranslations} isAskAiActive={isAskAiActive} />}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,291 +1,43 @@
|
|||
import {
|
||||
type AutocompleteSource,
|
||||
type AlgoliaInsightsHit,
|
||||
createAutocomplete,
|
||||
type AutocompleteState,
|
||||
} from '@algolia/autocomplete-core';
|
||||
import type { InitialAskAiMessage, OnAskAiToggle } from '@docsearch/core';
|
||||
import { useTheme } from '@docsearch/core/useTheme';
|
||||
import type { ChatRequestOptions } from 'ai';
|
||||
import type { SearchResponse } from 'algoliasearch/lite';
|
||||
import { createAutocomplete } from '@algolia/autocomplete-core';
|
||||
import React, { type JSX } from 'react';
|
||||
|
||||
import { MAX_QUERY_SIZE } from './constants';
|
||||
import type { DocSearchIndex, DocSearchProps } from './DocSearch';
|
||||
import type { KeywordSearchBoxTranslations } from './components/KeywordSearchBox';
|
||||
import { KeywordSearchBox } from './components/KeywordSearchBox';
|
||||
import { ModalShell } from './components/ui/ModalShell';
|
||||
import type { DocSearchProps } from './DocSearch';
|
||||
import type { FooterTranslations } from './Footer';
|
||||
import { Footer } from './Footer';
|
||||
import { Hit } from './Hit';
|
||||
import type { NewConversationTranslations } from './NewConversationScreen';
|
||||
import { useSendItemClickEvent } from './hooks/useDocSearchInsights';
|
||||
import { useInitialModalQuery } from './hooks/useInitialModalQuery';
|
||||
import { useModalEnvironment } from './hooks/useModalEnvironment';
|
||||
import { useModalRefs } from './hooks/useModalRefs';
|
||||
import { useRefreshOnInitialQuery } from './hooks/useRefreshOnInitialQuery';
|
||||
import { useSaveRecentSearch } from './hooks/useSaveRecentSearch';
|
||||
import { useStoredDocSearches } from './hooks/useStoredDocSearches';
|
||||
import type { ScreenStateTranslations } from './ScreenState';
|
||||
import { ScreenState } from './ScreenState';
|
||||
import type { SearchBoxTranslations } from './SearchBox';
|
||||
import { SearchBox } from './SearchBox';
|
||||
import { createStoredSearches } from './stored-searches';
|
||||
import type {
|
||||
DocSearchHit,
|
||||
DocSearchState,
|
||||
InternalDocSearchHit,
|
||||
StoredAskAiMessage,
|
||||
StoredDocSearchHit,
|
||||
SuggestedQuestionHit,
|
||||
} from './types';
|
||||
import type { AIMessage, AskAiState } from './types/AskiAi';
|
||||
import { useAskAi } from './useAskAi';
|
||||
import type { DocSearchState, InternalDocSearchHit } from './types';
|
||||
import { useSearchClient } from './useSearchClient';
|
||||
import { useSuggestedQuestions } from './useSuggestedQuestions';
|
||||
import { useTouchEvents } from './useTouchEvents';
|
||||
import { useTrapFocus } from './useTrapFocus';
|
||||
import { groupBy, identity, noop, removeHighlightTags, isModifierEvent, scrollTo as scrollToUtils } from './utils';
|
||||
import { buildDummyAskAiHit, isThreadDepthError } from './utils/ai';
|
||||
import { manageLocalStorageQuota } from './utils/storage';
|
||||
import { identity, isModifierEvent, noop, scrollTo as scrollToUtils } from './utils';
|
||||
import { buildNoQuerySources, buildQuerySources, type BuildQuerySourcesState } from './utils/createDocSearchSources';
|
||||
import { normalizeDocSearchIndexes } from './utils/normalizeDocSearchIndexes';
|
||||
|
||||
export type ModalTranslations = Partial<{
|
||||
searchBox: SearchBoxTranslations;
|
||||
newConversation: NewConversationTranslations;
|
||||
searchBox: KeywordSearchBoxTranslations;
|
||||
footer: FooterTranslations;
|
||||
}> &
|
||||
ScreenStateTranslations;
|
||||
|
||||
export type DocSearchModalProps = DocSearchProps & {
|
||||
initialScrollY: number;
|
||||
onAskAiToggle: OnAskAiToggle;
|
||||
interceptAskAiEvent?: (initialMessage: InitialAskAiMessage) => boolean | void;
|
||||
onClose?: () => void;
|
||||
isAskAiActive?: boolean;
|
||||
translations?: ModalTranslations;
|
||||
isHybridModeSupported?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper function to build sources when there is no query
|
||||
* useful for recent searches and favorite searches.
|
||||
*/
|
||||
type BuildNoQuerySourcesOptions = {
|
||||
recentSearches: ReturnType<typeof createStoredSearches>;
|
||||
favoriteSearches: ReturnType<typeof createStoredSearches>;
|
||||
saveRecentSearch: (item: InternalDocSearchHit) => void;
|
||||
onClose: () => void;
|
||||
disableUserPersonalization: boolean;
|
||||
canHandleAskAi: boolean;
|
||||
};
|
||||
|
||||
const buildNoQuerySources = ({
|
||||
recentSearches,
|
||||
favoriteSearches,
|
||||
saveRecentSearch,
|
||||
onClose,
|
||||
disableUserPersonalization,
|
||||
}: BuildNoQuerySourcesOptions): Array<AutocompleteSource<InternalDocSearchHit>> => {
|
||||
if (disableUserPersonalization) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const sources: Array<AutocompleteSource<InternalDocSearchHit>> = [
|
||||
{
|
||||
sourceId: 'recentSearches',
|
||||
onSelect({ item, event }): void {
|
||||
saveRecentSearch(item);
|
||||
if (!isModifierEvent(event)) {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
getItemUrl({ item }): string {
|
||||
return item.url;
|
||||
},
|
||||
getItems(): InternalDocSearchHit[] {
|
||||
return recentSearches.getAll() as InternalDocSearchHit[];
|
||||
},
|
||||
},
|
||||
{
|
||||
sourceId: 'favoriteSearches',
|
||||
onSelect({ item, event }): void {
|
||||
saveRecentSearch(item);
|
||||
if (!isModifierEvent(event)) {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
getItemUrl({ item }): string {
|
||||
return item.url;
|
||||
},
|
||||
getItems(): InternalDocSearchHit[] {
|
||||
return favoriteSearches.getAll() as InternalDocSearchHit[];
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return sources;
|
||||
};
|
||||
|
||||
type BuildQuerySourcesState = Pick<AutocompleteState<InternalDocSearchHit>, 'context'>;
|
||||
|
||||
/**
|
||||
* Helper function to build sources when there is a query
|
||||
* note: we only need specific parts of the state, not the full DocSearchState.
|
||||
*/
|
||||
const buildQuerySources = async ({
|
||||
query,
|
||||
state: sourcesState,
|
||||
setContext,
|
||||
setStatus,
|
||||
searchClient,
|
||||
indexes: indices,
|
||||
snippetLength,
|
||||
insights,
|
||||
appId,
|
||||
apiKey,
|
||||
maxResultsPerGroup,
|
||||
transformItems = identity,
|
||||
saveRecentSearch,
|
||||
onClose,
|
||||
}: {
|
||||
query: string;
|
||||
state: BuildQuerySourcesState;
|
||||
setContext: (context: Partial<DocSearchState<InternalDocSearchHit>['context']>) => void;
|
||||
setStatus: (status: DocSearchState<InternalDocSearchHit>['status']) => void;
|
||||
searchClient: ReturnType<typeof useSearchClient>;
|
||||
indexes: DocSearchIndex[];
|
||||
snippetLength: React.MutableRefObject<number>;
|
||||
insights: boolean;
|
||||
appId?: string;
|
||||
apiKey?: string;
|
||||
maxResultsPerGroup?: number;
|
||||
transformItems?: DocSearchProps['transformItems'];
|
||||
saveRecentSearch: (item: InternalDocSearchHit) => void;
|
||||
onClose: () => void;
|
||||
}): Promise<Array<AutocompleteSource<InternalDocSearchHit>>> => {
|
||||
const insightsActive = insights;
|
||||
|
||||
try {
|
||||
const { results } = await searchClient.search<DocSearchHit>({
|
||||
requests: indices.map((index) => {
|
||||
const indexName = typeof index === 'string' ? index : index.name;
|
||||
const searchParams = typeof index === 'string' ? {} : index.searchParameters;
|
||||
|
||||
return {
|
||||
query,
|
||||
indexName,
|
||||
attributesToRetrieve: searchParams?.attributesToRetrieve ?? [
|
||||
'hierarchy.lvl0',
|
||||
'hierarchy.lvl1',
|
||||
'hierarchy.lvl2',
|
||||
'hierarchy.lvl3',
|
||||
'hierarchy.lvl4',
|
||||
'hierarchy.lvl5',
|
||||
'hierarchy.lvl6',
|
||||
'content',
|
||||
'type',
|
||||
'url',
|
||||
],
|
||||
attributesToSnippet: searchParams?.attributesToSnippet ?? [
|
||||
`hierarchy.lvl1:${snippetLength.current}`,
|
||||
`hierarchy.lvl2:${snippetLength.current}`,
|
||||
`hierarchy.lvl3:${snippetLength.current}`,
|
||||
`hierarchy.lvl4:${snippetLength.current}`,
|
||||
`hierarchy.lvl5:${snippetLength.current}`,
|
||||
`hierarchy.lvl6:${snippetLength.current}`,
|
||||
`content:${snippetLength.current}`,
|
||||
],
|
||||
snippetEllipsisText: searchParams?.snippetEllipsisText ?? '…',
|
||||
highlightPreTag: searchParams?.highlightPreTag ?? '<mark>',
|
||||
highlightPostTag: searchParams?.highlightPostTag ?? '</mark>',
|
||||
hitsPerPage: searchParams?.hitsPerPage ?? 20,
|
||||
clickAnalytics: searchParams?.clickAnalytics ?? insightsActive,
|
||||
...(searchParams ?? {}),
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
return results.flatMap((res) => {
|
||||
const result = res as SearchResponse<DocSearchHit>;
|
||||
const { hits, nbHits } = result;
|
||||
const transformedHits = transformItems(hits);
|
||||
const sources = groupBy<DocSearchHit>(transformedHits, (hit) => removeHighlightTags(hit), maxResultsPerGroup);
|
||||
|
||||
// We store the `lvl0`s to display them as search suggestions
|
||||
// in the "no results" screen.
|
||||
if ((sourcesState.context.searchSuggestions as any[]).length < Object.keys(sources).length) {
|
||||
setContext({
|
||||
searchSuggestions: {
|
||||
...(sourcesState.context.searchSuggestions ?? []),
|
||||
...Object.keys(sources),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (nbHits) {
|
||||
const currentNbHits = sourcesState.context.nbHits as number | undefined;
|
||||
setContext({
|
||||
nbHits: (currentNbHits ?? 0) + nbHits,
|
||||
});
|
||||
}
|
||||
|
||||
let insightsParams = {};
|
||||
|
||||
if (insightsActive) {
|
||||
insightsParams = {
|
||||
__autocomplete_indexName: result.index,
|
||||
__autocomplete_queryID: result.queryID,
|
||||
__autocomplete_algoliaCredentials: {
|
||||
appId,
|
||||
apiKey,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return Object.values<DocSearchHit[]>(sources).map((items, index) => {
|
||||
return {
|
||||
sourceId: `hits_${result.index}_${index}`,
|
||||
onSelect({ item, event }): void {
|
||||
saveRecentSearch(item);
|
||||
if (!isModifierEvent(event)) {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
getItemUrl({ item }): string {
|
||||
return item.url;
|
||||
},
|
||||
getItems(): InternalDocSearchHit[] {
|
||||
return Object.values(groupBy(items, (item) => item.hierarchy.lvl1, maxResultsPerGroup))
|
||||
.map((groupedHits) =>
|
||||
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;
|
||||
|
||||
if (item.type !== 'lvl1' && potentialParent) {
|
||||
parent = potentialParent;
|
||||
}
|
||||
|
||||
return {
|
||||
...item,
|
||||
__docsearch_parent: parent,
|
||||
...insightsParams,
|
||||
};
|
||||
}),
|
||||
)
|
||||
.flat();
|
||||
},
|
||||
};
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
// The Algolia `RetryError` happens when all the servers have
|
||||
// failed, meaning that there's no chance the response comes
|
||||
// back. This is the right time to display an error.
|
||||
// See https://github.com/algolia/algoliasearch-client-javascript/blob/2ffddf59bc765cd1b664ee0346b28f00229d6e12/packages/transporter/src/errors/createRetryError.ts#L5
|
||||
if ((error as Error).name === 'RetryError') {
|
||||
setStatus('error');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export function DocSearchModal({
|
||||
appId,
|
||||
apiKey,
|
||||
askAi,
|
||||
maxResultsPerGroup,
|
||||
theme,
|
||||
onClose = noop,
|
||||
|
|
@ -300,15 +52,11 @@ export function DocSearchModal({
|
|||
translations = {},
|
||||
getMissingResultsUrl,
|
||||
insights = false,
|
||||
onAskAiToggle,
|
||||
interceptAskAiEvent,
|
||||
isAskAiActive = false,
|
||||
recentSearchesLimit = 7,
|
||||
recentSearchesWithFavoritesLimit = 4,
|
||||
indices = [],
|
||||
indexName,
|
||||
searchParameters,
|
||||
isHybridModeSupported = false,
|
||||
...props
|
||||
}: DocSearchModalProps): JSX.Element {
|
||||
const { footer: footerTranslations, searchBox: searchBoxTranslations, ...screenStateTranslations } = translations;
|
||||
|
|
@ -322,173 +70,31 @@ export function DocSearchModal({
|
|||
status: 'idle',
|
||||
});
|
||||
|
||||
// check if the instance is configured to handle ask ai
|
||||
const canHandleAskAi = Boolean(askAi);
|
||||
const placeholder = translations?.searchBox?.placeholderText || props.placeholder || 'Search docs';
|
||||
|
||||
let placeholder = translations?.searchBox?.placeholderText || props.placeholder || 'Search docs';
|
||||
|
||||
if (canHandleAskAi) {
|
||||
placeholder = translations?.searchBox?.placeholderText || 'Search docs or ask AI a question';
|
||||
}
|
||||
|
||||
if (isAskAiActive) {
|
||||
placeholder = translations?.searchBox?.placeholderTextAskAi || 'Ask another question...';
|
||||
}
|
||||
|
||||
const containerRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const modalRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const formElementRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const dropdownRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const inputRef = React.useRef<HTMLInputElement | null>(null);
|
||||
const snippetLength = React.useRef<number>(15);
|
||||
const initialQueryFromSelection = React.useRef(
|
||||
typeof window !== 'undefined' ? window.getSelection()!.toString().slice(0, MAX_QUERY_SIZE) : '',
|
||||
).current;
|
||||
const initialQuery = React.useRef(initialQueryFromProp || initialQueryFromSelection).current;
|
||||
const { containerRef, modalRef, formElementRef, dropdownRef, inputRef, snippetLength } = useModalRefs();
|
||||
const { initialQuery, initialQueryFromSelection } = useInitialModalQuery(initialQueryFromProp);
|
||||
|
||||
const searchClient = useSearchClient(appId, apiKey, transformSearchClient);
|
||||
|
||||
const askAiConfig = typeof askAi === 'object' ? askAi : null;
|
||||
const askAiConfigurationId = typeof askAi === 'string' ? askAi : askAiConfig?.assistantId || null;
|
||||
const askAiSearchParameters = askAiConfig?.searchParameters;
|
||||
const askAiUseStagingEnv = askAiConfig?.useStagingEnv || false;
|
||||
const [askAiState, setAskAiState] = React.useState<AskAiState>('initial');
|
||||
const suggestedQuestions = useSuggestedQuestions({
|
||||
assistantId: askAiConfigurationId,
|
||||
searchClient,
|
||||
suggestedQuestionsEnabled: askAiConfig?.suggestedQuestions,
|
||||
const indexes = normalizeDocSearchIndexes({
|
||||
indexName,
|
||||
indices,
|
||||
searchParameters,
|
||||
});
|
||||
const agentStudio = askAiConfig?.agentStudio ?? false;
|
||||
|
||||
// Format the `indexes` to be used until `indexName` and `searchParameters` props are fully removed.
|
||||
const indexes: DocSearchIndex[] = [];
|
||||
|
||||
if (indexName && indexName !== '') {
|
||||
indexes.push({
|
||||
name: indexName,
|
||||
searchParameters,
|
||||
});
|
||||
}
|
||||
|
||||
if (indices.length > 0) {
|
||||
indices.forEach((index) => {
|
||||
indexes.push(typeof index === 'string' ? { name: index } : index);
|
||||
});
|
||||
}
|
||||
|
||||
if (indexes.length < 1) {
|
||||
throw new Error('Must supply either `indexName` or `indices` for DocSearch to work');
|
||||
}
|
||||
|
||||
const defaultIndexName = indexes[0].name;
|
||||
|
||||
// storage
|
||||
const favoriteSearches = React.useRef(
|
||||
createStoredSearches<StoredDocSearchHit>({
|
||||
key: `__DOCSEARCH_FAVORITE_SEARCHES__${defaultIndexName}`,
|
||||
limit: 10,
|
||||
}),
|
||||
).current;
|
||||
const recentSearches = React.useRef(
|
||||
createStoredSearches<StoredDocSearchHit>({
|
||||
key: `__DOCSEARCH_RECENT_SEARCHES__${defaultIndexName}`,
|
||||
limit: favoriteSearches.getAll().length === 0 ? recentSearchesLimit : recentSearchesWithFavoritesLimit,
|
||||
}),
|
||||
).current;
|
||||
|
||||
const [stoppedStream, setStoppedStream] = React.useState(false);
|
||||
|
||||
const { messages, status, setMessages, sendMessage, stopAskAiStreaming, askAiError, sendFeedback, conversations } =
|
||||
useAskAi({
|
||||
assistantId: askAiConfigurationId,
|
||||
apiKey: askAiConfig?.apiKey || apiKey,
|
||||
appId: askAiConfig?.appId || appId,
|
||||
indexName: askAiConfig?.indexName || defaultIndexName,
|
||||
searchParameters: askAiSearchParameters,
|
||||
useStagingEnv: askAiUseStagingEnv,
|
||||
agentStudio,
|
||||
});
|
||||
|
||||
const prevStatus = React.useRef(status);
|
||||
React.useEffect(() => {
|
||||
if (disableUserPersonalization) {
|
||||
return;
|
||||
}
|
||||
// if we just transitioned from "streaming" → "ready", persist
|
||||
if (prevStatus.current === 'streaming' && status === 'ready') {
|
||||
// if we stopped the stream, store it on the most recent message
|
||||
if (stoppedStream && messages.at(-1)) {
|
||||
messages.at(-1)!.metadata = {
|
||||
stopped: true,
|
||||
};
|
||||
}
|
||||
|
||||
for (const part of messages[0].parts) {
|
||||
if (part.type === 'text') {
|
||||
conversations.add(buildDummyAskAiHit(part.text, messages));
|
||||
}
|
||||
}
|
||||
}
|
||||
prevStatus.current = status;
|
||||
}, [status, messages, conversations, disableUserPersonalization, stoppedStream]);
|
||||
|
||||
// Check if there's a thread depth error (AI-217)
|
||||
const hasThreadDepthError = React.useMemo(() => {
|
||||
return status === 'error' && isThreadDepthError(askAiError as Error | undefined);
|
||||
}, [status, askAiError]);
|
||||
|
||||
const createSyntheticParent = React.useCallback(function createSyntheticParent(
|
||||
item: InternalDocSearchHit,
|
||||
): InternalDocSearchHit {
|
||||
// Find the deepest non-null hierarchy level
|
||||
const hierarchy = item.hierarchy;
|
||||
const levels = ['lvl6', 'lvl5', 'lvl4', 'lvl3', 'lvl2', 'lvl1', 'lvl0'] as const;
|
||||
|
||||
const deepestLevel = levels.find((level) => hierarchy[level]);
|
||||
|
||||
return {
|
||||
...item,
|
||||
type: deepestLevel || 'lvl0', // Use the deepest available level as type
|
||||
content: null, // Clear content since this represents a section, not specific content
|
||||
};
|
||||
}, []);
|
||||
|
||||
const saveRecentSearch = React.useCallback(
|
||||
function saveRecentSearch(item: InternalDocSearchHit) {
|
||||
if (disableUserPersonalization) {
|
||||
return;
|
||||
}
|
||||
|
||||
// We don't store `content` record, but their parent if available.
|
||||
// If no parent exists, create a synthetic parent from the hierarchy.
|
||||
const search = item.type === 'content' ? item.__docsearch_parent || createSyntheticParent(item) : item;
|
||||
|
||||
// We save the recent search only if it's not favorited.
|
||||
if (search && favoriteSearches.getAll().findIndex((x) => x.objectID === search.objectID) === -1) {
|
||||
recentSearches.add(search);
|
||||
}
|
||||
},
|
||||
[favoriteSearches, recentSearches, disableUserPersonalization, createSyntheticParent],
|
||||
);
|
||||
|
||||
const sendItemClickEvent = React.useCallback(
|
||||
(item: InternalDocSearchHit) => {
|
||||
if (!state.context.algoliaInsightsPlugin || !item.__autocomplete_id) return;
|
||||
|
||||
const insightsItem = item as AlgoliaInsightsHit;
|
||||
|
||||
const insightsClickParams = {
|
||||
eventName: 'Item Selected',
|
||||
index: insightsItem.__autocomplete_indexName,
|
||||
items: [insightsItem],
|
||||
positions: [item.__autocomplete_id],
|
||||
queryID: insightsItem.__autocomplete_queryID,
|
||||
};
|
||||
|
||||
state.context.algoliaInsightsPlugin.insights.clickedObjectIDsAfterSearch(insightsClickParams);
|
||||
},
|
||||
[state.context.algoliaInsightsPlugin],
|
||||
);
|
||||
const { favoriteSearches, recentSearches } = useStoredDocSearches({
|
||||
defaultIndexName,
|
||||
recentSearchesLimit,
|
||||
recentSearchesWithFavoritesLimit,
|
||||
});
|
||||
const saveRecentSearch = useSaveRecentSearch({
|
||||
favoriteSearches,
|
||||
recentSearches,
|
||||
disableUserPersonalization,
|
||||
});
|
||||
const sendItemClickEvent = useSendItemClickEvent(state);
|
||||
|
||||
const autocompleteRef =
|
||||
React.useRef<
|
||||
|
|
@ -502,87 +108,6 @@ export function DocSearchModal({
|
|||
>
|
||||
>(undefined);
|
||||
|
||||
const handleSelectAskAiQuestion = React.useCallback(
|
||||
(toggle: boolean, query: string, suggestedQuestion: SuggestedQuestionHit | undefined = undefined) => {
|
||||
if (toggle) {
|
||||
const initialMessage: InitialAskAiMessage = {
|
||||
query,
|
||||
suggestedQuestionId: suggestedQuestion?.objectID,
|
||||
};
|
||||
|
||||
if (interceptAskAiEvent?.(initialMessage)) {
|
||||
// Consumer handled it. Avoid *all* default Ask AI behavior.
|
||||
if (autocompleteRef.current) {
|
||||
autocompleteRef.current.setQuery('');
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (toggle && askAiState === 'new-conversation') {
|
||||
setAskAiState('initial');
|
||||
}
|
||||
|
||||
onAskAiToggle(toggle, {
|
||||
query,
|
||||
suggestedQuestionId: suggestedQuestion?.objectID,
|
||||
});
|
||||
|
||||
// If we're in hybrid mode, we don't need to send the message,
|
||||
// it will be handled by the Sidepanel.
|
||||
if (isHybridModeSupported) return;
|
||||
|
||||
setStoppedStream(false);
|
||||
|
||||
const messageOptions: ChatRequestOptions = {};
|
||||
|
||||
if (suggestedQuestion) {
|
||||
messageOptions.body = {
|
||||
suggestedQuestionId: suggestedQuestion.objectID,
|
||||
};
|
||||
}
|
||||
|
||||
sendMessage(
|
||||
{
|
||||
role: 'user',
|
||||
parts: [
|
||||
{
|
||||
type: 'text',
|
||||
text: query,
|
||||
},
|
||||
],
|
||||
},
|
||||
messageOptions,
|
||||
);
|
||||
|
||||
if (dropdownRef.current) {
|
||||
// some test environments (like jsdom) don't implement element.scrollTo
|
||||
const el = dropdownRef.current;
|
||||
if (typeof (el as any).scrollTo === 'function') {
|
||||
el.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
} else {
|
||||
// fallback for environments without scrollTo support
|
||||
el.scrollTop = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// clear the query
|
||||
if (autocompleteRef.current) {
|
||||
autocompleteRef.current.setQuery('');
|
||||
}
|
||||
},
|
||||
[onAskAiToggle, interceptAskAiEvent, sendMessage, askAiState, setAskAiState, isHybridModeSupported],
|
||||
);
|
||||
|
||||
// feedback handler
|
||||
const handleFeedbackSubmit = React.useCallback(
|
||||
async (messageId: string, thumbs: 0 | 1): Promise<void> => {
|
||||
if (!askAiConfigurationId || !appId) return;
|
||||
await sendFeedback(messageId, thumbs);
|
||||
},
|
||||
[askAiConfigurationId, appId, sendFeedback],
|
||||
);
|
||||
|
||||
if (!autocompleteRef.current) {
|
||||
autocompleteRef.current = createAutocomplete({
|
||||
id: 'docsearch',
|
||||
|
|
@ -607,30 +132,8 @@ export function DocSearchModal({
|
|||
saveRecentSearch,
|
||||
onClose,
|
||||
disableUserPersonalization,
|
||||
canHandleAskAi,
|
||||
});
|
||||
|
||||
const recentConversationSource: Array<AutocompleteSource<InternalDocSearchHit & { messages?: AIMessage[] }>> =
|
||||
canHandleAskAi
|
||||
? [
|
||||
{
|
||||
sourceId: 'recentConversations',
|
||||
getItems(): InternalDocSearchHit[] {
|
||||
if (disableUserPersonalization) {
|
||||
return [];
|
||||
}
|
||||
return conversations.getAll() as unknown as InternalDocSearchHit[];
|
||||
},
|
||||
onSelect({ item }): void {
|
||||
if (item.messages) {
|
||||
setMessages(item.messages as any);
|
||||
onAskAiToggle(true);
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
: [];
|
||||
return [...noQuerySources, ...recentConversationSource];
|
||||
return noQuerySources;
|
||||
}
|
||||
|
||||
const querySourcesState: BuildQuerySourcesState = {
|
||||
|
|
@ -654,49 +157,7 @@ export function DocSearchModal({
|
|||
onClose,
|
||||
});
|
||||
|
||||
// Ask AI source
|
||||
const askAiSource: Array<AutocompleteSource<InternalDocSearchHit>> = canHandleAskAi
|
||||
? [
|
||||
{
|
||||
sourceId: 'askAI',
|
||||
getItems(): InternalDocSearchHit[] {
|
||||
// return a single item representing the Ask AI action
|
||||
// placeholder data matching the InternalDocSearchHit structure
|
||||
const askItem: InternalDocSearchHit = {
|
||||
type: 'askAI',
|
||||
query,
|
||||
url_without_anchor: '',
|
||||
objectID: `ask-ai-button`,
|
||||
content: null,
|
||||
url: '',
|
||||
anchor: null,
|
||||
hierarchy: {
|
||||
lvl0: 'Ask AI', // Or contextually relevant
|
||||
lvl1: query,
|
||||
lvl2: null,
|
||||
lvl3: null,
|
||||
lvl4: null,
|
||||
lvl5: null,
|
||||
lvl6: null,
|
||||
},
|
||||
_highlightResult: {} as any,
|
||||
_snippetResult: {} as any,
|
||||
__docsearch_parent: null,
|
||||
};
|
||||
return [askItem];
|
||||
},
|
||||
onSelect({ item }): void {
|
||||
if (item.type === 'askAI' && item.query) {
|
||||
handleSelectAskAiQuestion(true, item.query);
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
: [];
|
||||
// Combine Algolia results (once resolved) with the Ask AI source
|
||||
return algoliaSourcesPromise.then((algoliaSources) => {
|
||||
return [...askAiSource, ...algoliaSources];
|
||||
});
|
||||
return algoliaSourcesPromise;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -705,253 +166,79 @@ export function DocSearchModal({
|
|||
|
||||
const { getEnvironmentProps, getRootProps, refresh } = autocomplete;
|
||||
|
||||
useTouchEvents({
|
||||
useModalEnvironment({
|
||||
getEnvironmentProps,
|
||||
panelElement: dropdownRef.current,
|
||||
formElement: formElementRef.current,
|
||||
inputElement: inputRef.current,
|
||||
containerRef,
|
||||
dropdownRef,
|
||||
formElementRef,
|
||||
inputRef,
|
||||
initialScrollY,
|
||||
modalRef,
|
||||
snippetLength,
|
||||
theme,
|
||||
});
|
||||
useTrapFocus({ container: containerRef.current });
|
||||
useTheme({ theme });
|
||||
|
||||
React.useEffect(() => {
|
||||
document.body.classList.add('DocSearch--active');
|
||||
|
||||
return (): void => {
|
||||
document.body.classList.remove('DocSearch--active');
|
||||
|
||||
// IE11 doesn't support `scrollTo` so we check that the method exists
|
||||
// first.
|
||||
window.scrollTo?.(0, initialScrollY);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Proactively manage localStorage quota to prevent crashes
|
||||
React.useEffect(() => {
|
||||
manageLocalStorageQuota();
|
||||
}, []);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
// Calculate the scrollbar width to compensate for removed scrollbar
|
||||
const scrollBarWidth = window.innerWidth - document.body.clientWidth;
|
||||
// Prevent layout shift by adding appropriate margin to the body
|
||||
document.body.style.marginInlineEnd = `${scrollBarWidth}px`;
|
||||
|
||||
return (): void => {
|
||||
document.body.style.marginInlineEnd = '0px';
|
||||
};
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
const isMobileMediaQuery = window.matchMedia('(max-width: 768px)');
|
||||
|
||||
if (isMobileMediaQuery.matches) {
|
||||
snippetLength.current = 5;
|
||||
}
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (dropdownRef.current && !isAskAiActive) {
|
||||
if (dropdownRef.current) {
|
||||
scrollToUtils(dropdownRef.current);
|
||||
}
|
||||
}, [state.query, isAskAiActive]);
|
||||
}, [state.query, dropdownRef]);
|
||||
|
||||
// We don't focus the input when there's an initial query (i.e. Selection
|
||||
// Search) because users rather want to see the results directly, without the
|
||||
// keyboard appearing.
|
||||
// We therefore need to refresh the autocomplete instance to load all the
|
||||
// results, which is usually triggered on focus.
|
||||
React.useEffect(() => {
|
||||
if (initialQuery.length > 0) {
|
||||
refresh();
|
||||
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}
|
||||
}, [initialQuery, refresh]);
|
||||
|
||||
// We rely on a CSS property to set the modal height to the full viewport height
|
||||
// because all mobile browsers don't compute their height the same way.
|
||||
// See https://css-tricks.com/the-trick-to-viewport-units-on-mobile/
|
||||
React.useEffect(() => {
|
||||
function setFullViewportHeight(): void {
|
||||
if (modalRef.current) {
|
||||
const vh = window.innerHeight * 0.01;
|
||||
modalRef.current.style.setProperty('--docsearch-vh', `${vh}px`);
|
||||
}
|
||||
}
|
||||
|
||||
setFullViewportHeight();
|
||||
|
||||
window.addEventListener('resize', setFullViewportHeight);
|
||||
|
||||
return (): void => {
|
||||
window.removeEventListener('resize', setFullViewportHeight);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Refresh the autocomplete results when ask ai is toggled off
|
||||
// helps return to the previous ac state and start screen
|
||||
React.useEffect(() => {
|
||||
if (!isAskAiActive) {
|
||||
autocomplete.refresh();
|
||||
setMessages([]);
|
||||
}
|
||||
}, [isAskAiActive, autocomplete, setMessages]);
|
||||
|
||||
// Track external state in order to manage internal askAiState
|
||||
React.useEffect(() => {
|
||||
setAskAiState('initial');
|
||||
}, [isAskAiActive, setAskAiState]);
|
||||
|
||||
const onStopAskAiStreaming = async (): Promise<void> => {
|
||||
setStoppedStream(true);
|
||||
|
||||
await stopAskAiStreaming();
|
||||
};
|
||||
|
||||
const handleNewConversation = (): void => {
|
||||
setMessages([]);
|
||||
setAskAiState('new-conversation');
|
||||
};
|
||||
|
||||
const handleViewConversationHistory = (): void => {
|
||||
setAskAiState('conversation-history');
|
||||
};
|
||||
|
||||
const selectSuggestedQuestion = (suggestedQuestion: SuggestedQuestionHit): void => {
|
||||
handleSelectAskAiQuestion(true, suggestedQuestion.question, suggestedQuestion);
|
||||
};
|
||||
useRefreshOnInitialQuery({ initialQuery, inputRef, refresh });
|
||||
|
||||
// hide the dropdown on idle and no collections
|
||||
let showDocsearchDropdown = true;
|
||||
const hasCollections = state.collections.some((collection) => collection.items.length > 0);
|
||||
if (state.status === 'idle' && hasCollections === false && state.query.length === 0 && !isAskAiActive) {
|
||||
if (state.status === 'idle' && hasCollections === false && state.query.length === 0) {
|
||||
showDocsearchDropdown = false;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
{...getRootProps({ 'aria-expanded': true })}
|
||||
className={[
|
||||
'DocSearch',
|
||||
'DocSearch-Container',
|
||||
state.status === 'stalled' && 'DocSearch-Container--Stalled',
|
||||
state.status === 'error' && 'DocSearch-Container--Errored',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onMouseDown={(event) => {
|
||||
if (event.target === event.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="DocSearch-Modal" ref={modalRef}>
|
||||
<header className="DocSearch-SearchBar" ref={formElementRef}>
|
||||
<SearchBox
|
||||
{...autocomplete}
|
||||
state={state}
|
||||
placeholder={placeholder || 'Search docs'}
|
||||
autoFocus={initialQuery.length === 0}
|
||||
inputRef={inputRef}
|
||||
isFromSelection={Boolean(initialQuery) && initialQuery === initialQueryFromSelection}
|
||||
translations={searchBoxTranslations}
|
||||
isAskAiActive={isAskAiActive}
|
||||
askAiStatus={status}
|
||||
askAiError={askAiError}
|
||||
askAiState={askAiState}
|
||||
setAskAiState={setAskAiState}
|
||||
isThreadDepthError={hasThreadDepthError && askAiState !== 'new-conversation'}
|
||||
onClose={onClose}
|
||||
onAskAiToggle={onAskAiToggle}
|
||||
onAskAgain={(query) => {
|
||||
handleSelectAskAiQuestion(true, query);
|
||||
}}
|
||||
onStopAskAiStreaming={onStopAskAiStreaming}
|
||||
onNewConversation={handleNewConversation}
|
||||
onViewConversationHistory={handleViewConversationHistory}
|
||||
/>
|
||||
</header>
|
||||
|
||||
{showDocsearchDropdown && (
|
||||
<div className="DocSearch-Dropdown" ref={dropdownRef}>
|
||||
<ScreenState
|
||||
{...autocomplete}
|
||||
indexName={defaultIndexName}
|
||||
state={state}
|
||||
hitComponent={hitComponent}
|
||||
resultsFooterComponent={resultsFooterComponent}
|
||||
disableUserPersonalization={disableUserPersonalization}
|
||||
recentSearches={recentSearches}
|
||||
favoriteSearches={favoriteSearches}
|
||||
conversations={conversations}
|
||||
inputRef={inputRef}
|
||||
translations={screenStateTranslations}
|
||||
getMissingResultsUrl={getMissingResultsUrl}
|
||||
isAskAiActive={isAskAiActive}
|
||||
canHandleAskAi={canHandleAskAi}
|
||||
messages={messages}
|
||||
askAiError={askAiError}
|
||||
status={status}
|
||||
hasCollections={hasCollections}
|
||||
askAiState={askAiState}
|
||||
selectAskAiQuestion={handleSelectAskAiQuestion}
|
||||
suggestedQuestions={suggestedQuestions}
|
||||
selectSuggestedQuestion={selectSuggestedQuestion}
|
||||
agentStudio={agentStudio}
|
||||
onAskAiToggle={onAskAiToggle}
|
||||
onNewConversation={handleNewConversation}
|
||||
onItemClick={(item, event) => {
|
||||
// if the item is askAI toggle the screen
|
||||
if (item.type === 'askAI' && item.query) {
|
||||
// if the item is askAI and the anchor is stored
|
||||
if (item.anchor === 'stored' && 'messages' in item) {
|
||||
setMessages(item.messages as any);
|
||||
const initialMessage: InitialAskAiMessage = {
|
||||
query: item.query,
|
||||
messageId: (item.messages as StoredAskAiMessage[])[0].id,
|
||||
};
|
||||
|
||||
if (interceptAskAiEvent?.(initialMessage)) {
|
||||
if (autocompleteRef.current) {
|
||||
autocompleteRef.current.setQuery('');
|
||||
}
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
onAskAiToggle(true, initialMessage);
|
||||
} else {
|
||||
handleSelectAskAiQuestion(true, item.query);
|
||||
}
|
||||
setAskAiState('initial');
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
// If insights is active, send insights click event
|
||||
sendItemClickEvent(item);
|
||||
|
||||
saveRecentSearch(item);
|
||||
if (!isModifierEvent(event)) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
onFeedback={handleFeedbackSubmit}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<footer className="DocSearch-Footer">
|
||||
<Footer translations={footerTranslations} isAskAiActive={isAskAiActive} />
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
<ModalShell
|
||||
state={state}
|
||||
containerRef={containerRef}
|
||||
modalRef={modalRef}
|
||||
formElementRef={formElementRef}
|
||||
dropdownRef={dropdownRef}
|
||||
getRootProps={getRootProps}
|
||||
showDropdown={showDocsearchDropdown}
|
||||
searchBox={
|
||||
<KeywordSearchBox
|
||||
{...autocomplete}
|
||||
state={state}
|
||||
placeholder={placeholder || 'Search docs'}
|
||||
autoFocus={initialQuery.length === 0}
|
||||
inputRef={inputRef}
|
||||
isFromSelection={Boolean(initialQuery) && initialQuery === initialQueryFromSelection}
|
||||
translations={searchBoxTranslations}
|
||||
onClose={onClose}
|
||||
/>
|
||||
}
|
||||
screenState={
|
||||
<ScreenState
|
||||
{...autocomplete}
|
||||
indexName={defaultIndexName}
|
||||
state={state}
|
||||
hitComponent={hitComponent}
|
||||
resultsFooterComponent={resultsFooterComponent}
|
||||
disableUserPersonalization={disableUserPersonalization}
|
||||
recentSearches={recentSearches}
|
||||
favoriteSearches={favoriteSearches}
|
||||
inputRef={inputRef}
|
||||
translations={screenStateTranslations}
|
||||
getMissingResultsUrl={getMissingResultsUrl}
|
||||
hasCollections={hasCollections}
|
||||
onItemClick={(item, event) => {
|
||||
sendItemClickEvent(item);
|
||||
saveRecentSearch(item);
|
||||
if (!isModifierEvent(event)) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
}
|
||||
footer={<Footer translations={footerTranslations} />}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ export type NoResultsScreenTranslations = Partial<{
|
|||
}>;
|
||||
|
||||
type NoResultsScreenProps = Omit<ScreenStateProps<InternalDocSearchHit>, 'translations'> & {
|
||||
canHandleAskAi?: boolean;
|
||||
translations?: NoResultsScreenTranslations;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,32 +1,23 @@
|
|||
import type { UseChatHelpers } from '@ai-sdk/react';
|
||||
import type { AutocompleteApi, AutocompleteState, BaseItem } from '@algolia/autocomplete-core';
|
||||
import React from 'react';
|
||||
|
||||
import type { AskAiScreenTranslations } from './AskAiScreen';
|
||||
import { AskAiScreen } from './AskAiScreen';
|
||||
import { ConversationHistoryScreen } from './ConversationHistoryScreen';
|
||||
import type { KeywordStartScreenTranslations } from './components/KeywordStartScreen';
|
||||
import { KeywordStartScreen } from './components/KeywordStartScreen';
|
||||
import type { DocSearchProps } from './DocSearch';
|
||||
import type { ErrorScreenTranslations } from './ErrorScreen';
|
||||
import { ErrorScreen } from './ErrorScreen';
|
||||
import type { NewConversationTranslations } from './NewConversationScreen';
|
||||
import { NewConversationScreen } from './NewConversationScreen';
|
||||
import type { NoResultsScreenTranslations } from './NoResultsScreen';
|
||||
import { NoResultsScreen } from './NoResultsScreen';
|
||||
import type { ResultsScreenTranslations } from './ResultsScreen';
|
||||
import { ResultsScreen } from './ResultsScreen';
|
||||
import type { StartScreenTranslations } from './StartScreen';
|
||||
import { StartScreen } from './StartScreen';
|
||||
import type { StoredSearchPlugin } from './stored-searches';
|
||||
import type { InternalDocSearchHit, StoredAskAiState, StoredDocSearchHit, SuggestedQuestionHit } from './types';
|
||||
import type { AIMessage, AskAiState } from './types/AskiAi';
|
||||
import type { InternalDocSearchHit, StoredDocSearchHit } from './types';
|
||||
|
||||
export type ScreenStateTranslations = Partial<{
|
||||
errorScreen: ErrorScreenTranslations;
|
||||
startScreen: StartScreenTranslations;
|
||||
startScreen: KeywordStartScreenTranslations;
|
||||
noResultsScreen: NoResultsScreenTranslations;
|
||||
resultsScreen: ResultsScreenTranslations;
|
||||
askAiScreen: AskAiScreenTranslations;
|
||||
newConversation: NewConversationTranslations;
|
||||
}>;
|
||||
|
||||
export interface ScreenStateProps<TItem extends BaseItem>
|
||||
|
|
@ -34,81 +25,34 @@ export interface ScreenStateProps<TItem extends BaseItem>
|
|||
state: AutocompleteState<TItem>;
|
||||
recentSearches: StoredSearchPlugin<StoredDocSearchHit>;
|
||||
favoriteSearches: StoredSearchPlugin<StoredDocSearchHit>;
|
||||
conversations: StoredSearchPlugin<StoredAskAiState>;
|
||||
onItemClick: (item: InternalDocSearchHit, event: KeyboardEvent | MouseEvent) => void;
|
||||
onAskAiToggle: (toggle: boolean) => void;
|
||||
isAskAiActive: boolean;
|
||||
canHandleAskAi: boolean;
|
||||
inputRef: React.MutableRefObject<HTMLInputElement | null>;
|
||||
hitComponent: DocSearchProps['hitComponent'];
|
||||
indexName: DocSearchProps['indexName'];
|
||||
messages: UseChatHelpers<AIMessage>['messages'];
|
||||
status: UseChatHelpers<AIMessage>['status'];
|
||||
askAiError?: Error;
|
||||
disableUserPersonalization: boolean;
|
||||
resultsFooterComponent: DocSearchProps['resultsFooterComponent'];
|
||||
translations: ScreenStateTranslations;
|
||||
getMissingResultsUrl?: DocSearchProps['getMissingResultsUrl'];
|
||||
hasCollections: boolean;
|
||||
onFeedback?: (messageId: string, thumbs: 0 | 1) => Promise<void>;
|
||||
askAiState: AskAiState;
|
||||
selectAskAiQuestion: (toggle: boolean, query: string) => void;
|
||||
suggestedQuestions: SuggestedQuestionHit[];
|
||||
selectSuggestedQuestion: (question: SuggestedQuestionHit) => void;
|
||||
onNewConversation: () => void;
|
||||
agentStudio?: boolean;
|
||||
}
|
||||
|
||||
export const ScreenState = React.memo(
|
||||
({ translations = {}, ...props }: ScreenStateProps<InternalDocSearchHit>) => {
|
||||
if (props.canHandleAskAi && props.isAskAiActive && props.askAiState === 'conversation-history') {
|
||||
return <ConversationHistoryScreen {...props} />;
|
||||
}
|
||||
|
||||
if (props.canHandleAskAi && props.isAskAiActive && props.askAiState === 'new-conversation') {
|
||||
return (
|
||||
<NewConversationScreen
|
||||
translations={translations?.newConversation}
|
||||
selectSuggestedQuestion={props.selectSuggestedQuestion}
|
||||
suggestedQuestions={props.suggestedQuestions}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (props.isAskAiActive && props.canHandleAskAi) {
|
||||
return (
|
||||
<AskAiScreen
|
||||
{...props}
|
||||
messages={props.messages}
|
||||
status={props.status}
|
||||
askAiError={props.askAiError}
|
||||
translations={translations?.askAiScreen}
|
||||
agentStudio={props.agentStudio}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (props.state?.status === 'error') {
|
||||
return <ErrorScreen translations={translations?.errorScreen} />;
|
||||
}
|
||||
|
||||
if (!props.state.query) {
|
||||
return <StartScreen {...props} hasCollections={props.hasCollections} translations={translations?.startScreen} />;
|
||||
return (
|
||||
<KeywordStartScreen {...props} hasCollections={props.hasCollections} translations={translations?.startScreen} />
|
||||
);
|
||||
}
|
||||
|
||||
if (!props.hasCollections && !props.canHandleAskAi) {
|
||||
if (!props.hasCollections) {
|
||||
return <NoResultsScreen {...props} translations={translations?.noResultsScreen} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ResultsScreen {...props} translations={translations?.resultsScreen} />
|
||||
{props.canHandleAskAi && props.state.collections.length === 1 && (
|
||||
// if there's one collection it is the ask ai action, show the no results screen
|
||||
<NoResultsScreen {...props} translations={translations?.noResultsScreen} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
return <ResultsScreen {...props} translations={translations?.resultsScreen} />;
|
||||
},
|
||||
function areEqual(_prevProps, nextProps) {
|
||||
// We don't update the screen when Autocomplete is loading or stalled to
|
||||
|
|
|
|||
|
|
@ -5,12 +5,17 @@ import { describe, it, expect, afterEach } from 'vitest';
|
|||
import '@testing-library/jest-dom/vitest';
|
||||
|
||||
import { DocSearch as DocSearchComponent } from '../DocSearch';
|
||||
import type { DocSearchProps } from '../DocSearch';
|
||||
import type { DocSearchAIProps, DocSearchProps } from '../DocSearch';
|
||||
import { DocSearchAI as DocSearchAIComponent } from '../DocSearchAI';
|
||||
|
||||
function DocSearch(props: Partial<DocSearchProps>): JSX.Element {
|
||||
return <DocSearchComponent appId="woo" apiKey="foo" indexName="bar" {...props} />;
|
||||
}
|
||||
|
||||
function DocSearchAI(props: Partial<DocSearchAIProps>): JSX.Element {
|
||||
return <DocSearchAIComponent appId="woo" apiKey="foo" indexName="bar" askAi="assistant" {...props} />;
|
||||
}
|
||||
|
||||
// mock empty response
|
||||
function noResultSearch(_queries: any, _requestOptions?: any): Promise<any> {
|
||||
return new Promise((resolve) => {
|
||||
|
|
@ -264,7 +269,7 @@ describe('api', () => {
|
|||
|
||||
describe('ask AI integration', () => {
|
||||
it('updates placeholder when ask AI is available', async () => {
|
||||
render(<DocSearch askAi="assistant" />);
|
||||
render(<DocSearchAI />);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(await screen.findByText('Search'));
|
||||
|
|
@ -275,8 +280,7 @@ describe('api', () => {
|
|||
|
||||
it('opens ask AI screen and returns to search', async () => {
|
||||
render(
|
||||
<DocSearch
|
||||
askAi="assistant"
|
||||
<DocSearchAI
|
||||
transformSearchClient={(searchClient) => ({
|
||||
...searchClient,
|
||||
search: noResultSearch,
|
||||
|
|
|
|||
109
packages/docsearch-react/src/__tests__/modal.test.tsx
Normal file
109
packages/docsearch-react/src/__tests__/modal.test.tsx
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import { render, cleanup } from '@testing-library/react';
|
||||
import React, { type JSX } from 'react';
|
||||
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
|
||||
import { MAX_QUERY_SIZE } from '../constants';
|
||||
import { useInitialModalQuery } from '../hooks/useInitialModalQuery';
|
||||
import { useModalRefs } from '../hooks/useModalRefs';
|
||||
import { useRefreshOnInitialQuery } from '../hooks/useRefreshOnInitialQuery';
|
||||
|
||||
describe('modal hooks', () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('useInitialModalQuery', () => {
|
||||
function TestComponent({
|
||||
initialQueryFromProp,
|
||||
onResult,
|
||||
}: {
|
||||
initialQueryFromProp: string;
|
||||
onResult: (result: ReturnType<typeof useInitialModalQuery>) => void;
|
||||
}): null {
|
||||
onResult(useInitialModalQuery(initialQueryFromProp));
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
it('preserves explicit initial query precedence over selected text', () => {
|
||||
const onResult = vi.fn();
|
||||
vi.spyOn(window, 'getSelection').mockReturnValue({
|
||||
toString: () => 'selected text',
|
||||
} as Selection);
|
||||
|
||||
render(<TestComponent initialQueryFromProp="explicit query" onResult={onResult} />);
|
||||
|
||||
expect(onResult).toHaveBeenCalledWith({
|
||||
initialQuery: 'explicit query',
|
||||
initialQueryFromSelection: 'selected text',
|
||||
});
|
||||
});
|
||||
|
||||
it('slices selected text with MAX_QUERY_SIZE', () => {
|
||||
const onResult = vi.fn();
|
||||
const selection = 'a'.repeat(MAX_QUERY_SIZE + 10);
|
||||
vi.spyOn(window, 'getSelection').mockReturnValue({
|
||||
toString: () => selection,
|
||||
} as Selection);
|
||||
|
||||
render(<TestComponent initialQueryFromProp="" onResult={onResult} />);
|
||||
|
||||
expect(onResult).toHaveBeenCalledWith({
|
||||
initialQuery: selection.slice(0, MAX_QUERY_SIZE),
|
||||
initialQueryFromSelection: selection.slice(0, MAX_QUERY_SIZE),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('useRefreshOnInitialQuery', () => {
|
||||
function TestComponent({ initialQuery, refresh }: { initialQuery: string; refresh: () => void }): JSX.Element {
|
||||
const inputRef = React.useRef<HTMLInputElement | null>(null);
|
||||
useRefreshOnInitialQuery({ initialQuery, inputRef, refresh });
|
||||
|
||||
return <input ref={inputRef} />;
|
||||
}
|
||||
|
||||
it('calls refresh and focuses input only when an initial query exists', () => {
|
||||
const refresh = vi.fn();
|
||||
|
||||
const { rerender } = render(<TestComponent initialQuery="" refresh={refresh} />);
|
||||
|
||||
expect(refresh).not.toHaveBeenCalled();
|
||||
expect(document.activeElement).toBe(document.body);
|
||||
|
||||
rerender(<TestComponent initialQuery="query" refresh={refresh} />);
|
||||
|
||||
expect(refresh).toHaveBeenCalledTimes(1);
|
||||
expect(document.activeElement).toBe(document.querySelector('input'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('useModalRefs', () => {
|
||||
function TestComponent({ onResult }: { onResult: (result: ReturnType<typeof useModalRefs>) => void }): null {
|
||||
onResult(useModalRefs());
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
it('returns stable refs with the expected initial values', () => {
|
||||
const onResult = vi.fn();
|
||||
|
||||
const { rerender } = render(<TestComponent onResult={onResult} />);
|
||||
const firstResult = onResult.mock.calls[0][0] as ReturnType<typeof useModalRefs>;
|
||||
|
||||
expect(firstResult.containerRef.current).toBeNull();
|
||||
expect(firstResult.modalRef.current).toBeNull();
|
||||
expect(firstResult.formElementRef.current).toBeNull();
|
||||
expect(firstResult.dropdownRef.current).toBeNull();
|
||||
expect(firstResult.inputRef.current).toBeNull();
|
||||
expect(firstResult.snippetLength.current).toBe(15);
|
||||
|
||||
rerender(<TestComponent onResult={onResult} />);
|
||||
|
||||
expect(onResult.mock.calls[1][0]).toEqual(firstResult);
|
||||
});
|
||||
});
|
||||
});
|
||||
218
packages/docsearch-react/src/__tests__/searchbox.test.tsx
Normal file
218
packages/docsearch-react/src/__tests__/searchbox.test.tsx
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
|
||||
import { AskAiSearchBox } from '../components/AskAiSearchBox';
|
||||
import { KeywordSearchBox } from '../components/KeywordSearchBox';
|
||||
import { SearchBoxForm } from '../components/ui/SearchBoxForm';
|
||||
|
||||
function createState(overrides = {}): any {
|
||||
return {
|
||||
query: '',
|
||||
collections: [],
|
||||
status: 'idle',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createAutocomplete(overrides = {}): any {
|
||||
const onChange = vi.fn();
|
||||
const onKeyDown = vi.fn();
|
||||
|
||||
return {
|
||||
getFormProps: vi.fn(() => ({
|
||||
onReset: vi.fn(),
|
||||
})),
|
||||
getInputProps: vi.fn(() => ({
|
||||
'aria-autocomplete': 'list',
|
||||
onChange,
|
||||
onKeyDown,
|
||||
})),
|
||||
getLabelProps: vi.fn(() => ({
|
||||
htmlFor: 'docsearch-input',
|
||||
})),
|
||||
setQuery: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderSearchBoxForm(props = {}): ReturnType<typeof render> {
|
||||
const autocomplete = createAutocomplete();
|
||||
|
||||
return render(
|
||||
<SearchBoxForm
|
||||
{...autocomplete}
|
||||
state={createState()}
|
||||
autoFocus={false}
|
||||
inputRef={React.createRef<HTMLInputElement>()}
|
||||
isFromSelection={false}
|
||||
placeholder="Search docs"
|
||||
clearButtonTitle="Clear"
|
||||
clearButtonAriaLabel="Clear the query"
|
||||
closeButtonText="Close"
|
||||
closeButtonAriaLabel="Close"
|
||||
searchInputLabel="Search"
|
||||
onClose={vi.fn()}
|
||||
{...props}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('SearchBoxForm', () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders the shared form, input, clear button, close button, and search label', () => {
|
||||
renderSearchBoxForm({
|
||||
state: createState({ query: 'docsearch' }),
|
||||
});
|
||||
|
||||
expect(document.querySelector('.DocSearch-Form')).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText('Search docs')).toBeInTheDocument();
|
||||
expect(document.querySelector('.DocSearch-Clear')).toHaveTextContent('Clear');
|
||||
expect(screen.getByRole('button', { name: 'Close' })).toHaveAttribute('title', 'Close');
|
||||
expect(document.querySelector('.DocSearch-MagnifierLabel')).toHaveTextContent('Search');
|
||||
});
|
||||
|
||||
it('renders the loading indicator while keyword search is stalled', () => {
|
||||
renderSearchBoxForm({
|
||||
state: createState({ status: 'stalled' }),
|
||||
});
|
||||
|
||||
expect(document.querySelector('.DocSearch-LoadingIndicator')).toBeInTheDocument();
|
||||
expect(document.querySelector('.DocSearch-MagnifierLabel')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('allows scoped input props and slots to override base autocomplete props', () => {
|
||||
const onKeyDown = vi.fn();
|
||||
const onChange = vi.fn();
|
||||
|
||||
renderSearchBoxForm({
|
||||
leadingElement: <span data-testid="leading">Leading</span>,
|
||||
inputOverlay: <span data-testid="overlay">Overlay</span>,
|
||||
actionsBeforeClose: <button type="button">Action</button>,
|
||||
hideInput: true,
|
||||
inputProps: {
|
||||
disabled: true,
|
||||
enterKeyHint: 'enter',
|
||||
onChange,
|
||||
onKeyDown,
|
||||
},
|
||||
});
|
||||
|
||||
const input = screen.getByPlaceholderText('Search docs');
|
||||
|
||||
expect(screen.getByTestId('leading')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('overlay')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Action' })).toBeInTheDocument();
|
||||
expect(input).toBeDisabled();
|
||||
expect(input).toHaveAttribute('enterkeyhint', 'enter');
|
||||
expect(input).toHaveAttribute('hidden');
|
||||
|
||||
fireEvent.keyDown(input, { key: 'Enter' });
|
||||
fireEvent.change(input, { target: { value: 'ask' } });
|
||||
|
||||
expect(onKeyDown).toHaveBeenCalledTimes(1);
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('KeywordSearchBox', () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('passes keyword search defaults to the shared form', () => {
|
||||
render(
|
||||
<KeywordSearchBox
|
||||
{...createAutocomplete()}
|
||||
state={createState()}
|
||||
autoFocus={false}
|
||||
inputRef={React.createRef<HTMLInputElement>()}
|
||||
isFromSelection={false}
|
||||
placeholder="Search docs"
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(document.querySelector('.DocSearch-Clear')).toHaveTextContent('Clear');
|
||||
expect(screen.getByRole('button', { name: 'Close' })).toHaveAttribute('title', 'Close');
|
||||
expect(screen.getByPlaceholderText('Search docs')).toHaveAttribute('enterkeyhint', 'search');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AskAiSearchBox', () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function renderAskAiSearchBox(props = {}): ReturnType<typeof render> {
|
||||
return render(
|
||||
<AskAiSearchBox
|
||||
{...createAutocomplete()}
|
||||
state={createState({ query: 'follow up', collections: [null, null, { items: [{ objectID: '1' }] }] })}
|
||||
autoFocus={false}
|
||||
inputRef={React.createRef<HTMLInputElement>()}
|
||||
isFromSelection={false}
|
||||
placeholder="Ask another question..."
|
||||
isAskAiActive={true}
|
||||
askAiStatus="ready"
|
||||
askAiState="initial"
|
||||
setAskAiState={vi.fn()}
|
||||
onAskAgain={vi.fn()}
|
||||
onAskAiToggle={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
onNewConversation={vi.fn()}
|
||||
onStopAskAiStreaming={vi.fn()}
|
||||
onViewConversationHistory={vi.fn()}
|
||||
{...props}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
it('renders Ask AI-specific back action and conversation menu actions', () => {
|
||||
renderAskAiSearchBox();
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Back to keyword search' })).toBeInTheDocument();
|
||||
expect(screen.getByText('Start a new conversation')).toBeInTheDocument();
|
||||
expect(screen.getByText('Conversation history')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the stop streaming action and disables the input while streaming', () => {
|
||||
renderAskAiSearchBox({ askAiStatus: 'streaming' });
|
||||
|
||||
expect(document.querySelector('.DocSearch-StopStreaming')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('')).toBeDisabled();
|
||||
expect(screen.getByText('Answering...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('intercepts Enter while Ask AI is active to ask again', () => {
|
||||
const onAskAgain = vi.fn();
|
||||
|
||||
renderAskAiSearchBox({ onAskAgain });
|
||||
|
||||
fireEvent.keyDown(screen.getByPlaceholderText('Ask another question...'), { key: 'Enter' });
|
||||
|
||||
expect(onAskAgain).toHaveBeenCalledWith('follow up');
|
||||
});
|
||||
|
||||
it('uses thread-depth behavior in Ask AI mode', () => {
|
||||
const onNewConversation = vi.fn();
|
||||
|
||||
renderAskAiSearchBox({ isThreadDepthError: true, onNewConversation });
|
||||
|
||||
const input = screen.getByPlaceholderText('Conversation limit reached');
|
||||
|
||||
expect(input).toBeDisabled();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Back to keyword search' }));
|
||||
|
||||
expect(onNewConversation).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
192
packages/docsearch-react/src/__tests__/startscreen.test.tsx
Normal file
192
packages/docsearch-react/src/__tests__/startscreen.test.tsx
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
import { cleanup, fireEvent, render, screen } 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 { AskAiStartScreen } from '../components/AskAiStartScreen';
|
||||
import { KeywordStartScreen } from '../components/KeywordStartScreen';
|
||||
import { RecentConversationsResults } from '../components/ui/RecentConversationsResults';
|
||||
import { StoredSearchesSections } from '../components/ui/StoredSearchesSections';
|
||||
import type { InternalDocSearchHit, StoredAskAiState } from '../types';
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
function createHit(objectID: string, title: string): InternalDocSearchHit {
|
||||
return {
|
||||
objectID,
|
||||
content: null,
|
||||
url: `https://example.com/${objectID}`,
|
||||
url_without_anchor: `https://example.com/${objectID}`,
|
||||
type: 'lvl1',
|
||||
anchor: null,
|
||||
hierarchy: {
|
||||
lvl0: 'Docs',
|
||||
lvl1: title,
|
||||
lvl2: null,
|
||||
lvl3: null,
|
||||
lvl4: null,
|
||||
lvl5: null,
|
||||
lvl6: null,
|
||||
},
|
||||
_highlightResult: {
|
||||
content: { value: '', matchLevel: 'none', matchedWords: [] },
|
||||
hierarchy: {
|
||||
lvl0: { value: 'Docs', matchLevel: 'none', matchedWords: [] },
|
||||
lvl1: { value: title, 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: 'Docs', matchLevel: 'none', matchedWords: [] },
|
||||
lvl1: { value: title, 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: null,
|
||||
};
|
||||
}
|
||||
|
||||
function createCollection(sourceId: string, items: InternalDocSearchHit[]): unknown {
|
||||
return {
|
||||
source: { sourceId },
|
||||
items,
|
||||
};
|
||||
}
|
||||
|
||||
function Hit({ children }: { children: React.ReactNode }): JSX.Element {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
function createProps(collections: unknown[]) {
|
||||
return {
|
||||
state: {
|
||||
collections,
|
||||
query: '',
|
||||
status: 'idle',
|
||||
},
|
||||
recentSearches: {
|
||||
add: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
},
|
||||
favoriteSearches: {
|
||||
add: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
},
|
||||
conversations: {
|
||||
add: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
},
|
||||
refresh: vi.fn(),
|
||||
getListProps: vi.fn(() => ({})),
|
||||
getItemProps: vi.fn(({ onClick }) => ({ onClick })),
|
||||
onItemClick: vi.fn(),
|
||||
inputRef: React.createRef<HTMLInputElement>(),
|
||||
hitComponent: Hit,
|
||||
indexName: 'docs',
|
||||
disableUserPersonalization: false,
|
||||
resultsFooterComponent: null,
|
||||
hasCollections: true,
|
||||
} as any;
|
||||
}
|
||||
|
||||
describe('start screen components', () => {
|
||||
it('renders stored searches and runs save/remove actions', () => {
|
||||
const recentHit = createHit('recent', 'Recent result');
|
||||
const favoriteHit = createHit('favorite', 'Favorite result');
|
||||
const props = createProps([
|
||||
createCollection('recentSearches', [recentHit]),
|
||||
createCollection('favoriteSearches', [favoriteHit]),
|
||||
]);
|
||||
|
||||
render(<StoredSearchesSections {...props} />);
|
||||
|
||||
expect(screen.getByText('Recent result')).toBeInTheDocument();
|
||||
expect(screen.getByText('Favorite result')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByTitle('Save this search'));
|
||||
expect(props.favoriteSearches.add).toHaveBeenCalledWith(recentHit);
|
||||
expect(props.recentSearches.remove).toHaveBeenCalledWith(recentHit);
|
||||
|
||||
fireEvent.click(screen.getByTitle('Remove this search from history'));
|
||||
expect(props.recentSearches.remove).toHaveBeenCalledWith(recentHit);
|
||||
|
||||
fireEvent.click(screen.getByTitle('Remove this search from favorites'));
|
||||
expect(props.favoriteSearches.remove).toHaveBeenCalledWith(favoriteHit);
|
||||
expect(props.refresh).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('renders recent conversations and removes them', () => {
|
||||
const conversation = createHit('conversation', 'Conversation result') as InternalDocSearchHit & StoredAskAiState;
|
||||
const props = createProps([
|
||||
createCollection('recentSearches', []),
|
||||
createCollection('favoriteSearches', []),
|
||||
createCollection('recentConversations', [conversation]),
|
||||
]);
|
||||
|
||||
render(<RecentConversationsResults {...props} />);
|
||||
|
||||
expect(screen.getByText('Conversation result')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByTitle('Remove this conversation from history'));
|
||||
expect(props.conversations.remove).toHaveBeenCalledWith(conversation);
|
||||
expect(props.refresh).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('keeps the keyword start screen scoped to stored searches', () => {
|
||||
const props = createProps([
|
||||
createCollection('recentSearches', []),
|
||||
createCollection('favoriteSearches', []),
|
||||
createCollection('recentConversations', [createHit('conversation', 'Conversation result')]),
|
||||
]);
|
||||
|
||||
render(
|
||||
<KeywordStartScreen
|
||||
{...props}
|
||||
translations={{
|
||||
recentSearchesTitle: 'Search history',
|
||||
noRecentSearchesText: 'Nothing yet',
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText('Recent conversations')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('composes stored searches and recent conversations for Ask AI', () => {
|
||||
const props = createProps([
|
||||
createCollection('recentSearches', []),
|
||||
createCollection('favoriteSearches', []),
|
||||
createCollection('recentConversations', [createHit('conversation', 'Conversation result')]),
|
||||
]);
|
||||
|
||||
render(
|
||||
<AskAiStartScreen
|
||||
{...props}
|
||||
translations={{
|
||||
noRecentSearchesText: 'No saved searches',
|
||||
recentConversationsTitle: 'Recent chats',
|
||||
removeRecentConversationButtonTitle: 'Remove chat',
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Recent chats')).toBeInTheDocument();
|
||||
expect(screen.getByText('Conversation result')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('Remove chat')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -2,23 +2,16 @@ import type { UseChatHelpers } from '@ai-sdk/react';
|
|||
import type { AutocompleteApi, AutocompleteState } from '@algolia/autocomplete-core';
|
||||
import React, { type JSX, type RefObject } from 'react';
|
||||
|
||||
import { MAX_QUERY_SIZE } from './constants';
|
||||
import {
|
||||
LoadingIcon,
|
||||
CloseIcon,
|
||||
SearchIcon,
|
||||
StopIcon,
|
||||
MoreVerticalIcon,
|
||||
NewConversationIcon,
|
||||
ConversationHistoryIcon,
|
||||
} from './icons';
|
||||
import { BackIcon } from './icons/BackIcon';
|
||||
import { Menu } from './Menu';
|
||||
import { ModalHeading } from './ModalHeading';
|
||||
import type { InternalDocSearchHit } from './types';
|
||||
import type { AIMessage, AskAiState } from './types/AskiAi';
|
||||
import { ConversationHistoryIcon, MoreVerticalIcon, NewConversationIcon, StopIcon } from '../icons';
|
||||
import { BackIcon } from '../icons/BackIcon';
|
||||
import { Menu } from '../Menu';
|
||||
import { ModalHeading } from '../ModalHeading';
|
||||
import type { InternalDocSearchHit } from '../types';
|
||||
import type { AIMessage, AskAiState } from '../types/AskiAi';
|
||||
|
||||
export type SearchBoxTranslations = Partial<{
|
||||
import { SearchBoxForm } from './ui/SearchBoxForm';
|
||||
|
||||
export type AskAiSearchBoxTranslations = Partial<{
|
||||
clearButtonTitle: string;
|
||||
clearButtonAriaLabel: string;
|
||||
closeButtonText: string;
|
||||
|
|
@ -38,7 +31,7 @@ export type SearchBoxTranslations = Partial<{
|
|||
threadDepthErrorPlaceholder: string;
|
||||
}>;
|
||||
|
||||
interface SearchBoxProps
|
||||
interface AskAiSearchBoxProps
|
||||
extends AutocompleteApi<InternalDocSearchHit, React.FormEvent, React.MouseEvent, React.KeyboardEvent> {
|
||||
state: AutocompleteState<InternalDocSearchHit>;
|
||||
autoFocus: boolean;
|
||||
|
|
@ -52,7 +45,7 @@ interface SearchBoxProps
|
|||
askAiStatus: UseChatHelpers<AIMessage>['status'];
|
||||
askAiError?: Error;
|
||||
isFromSelection: boolean;
|
||||
translations?: SearchBoxTranslations;
|
||||
translations?: AskAiSearchBoxTranslations;
|
||||
askAiState: AskAiState;
|
||||
setAskAiState: (state: AskAiState) => void;
|
||||
onNewConversation: () => void;
|
||||
|
|
@ -60,13 +53,13 @@ interface SearchBoxProps
|
|||
isThreadDepthError?: boolean;
|
||||
}
|
||||
|
||||
export function SearchBox({
|
||||
export function AskAiSearchBox({
|
||||
translations = {},
|
||||
askAiState,
|
||||
onAskAiToggle,
|
||||
setAskAiState,
|
||||
...props
|
||||
}: SearchBoxProps): JSX.Element {
|
||||
}: AskAiSearchBoxProps): JSX.Element {
|
||||
const {
|
||||
clearButtonTitle = 'Clear',
|
||||
clearButtonAriaLabel = 'Clear the query',
|
||||
|
|
@ -82,21 +75,6 @@ export function SearchBox({
|
|||
viewConversationHistoryText = 'Conversation history',
|
||||
threadDepthErrorPlaceholder = 'Conversation limit reached',
|
||||
} = translations;
|
||||
const { onReset } = props.getFormProps({
|
||||
inputElement: props.inputRef.current,
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (props.autoFocus && props.inputRef.current) {
|
||||
props.inputRef.current.focus();
|
||||
}
|
||||
}, [props.autoFocus, props.inputRef]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (props.isFromSelection && props.inputRef.current) {
|
||||
props.inputRef.current.select();
|
||||
}
|
||||
}, [props.isFromSelection, props.inputRef]);
|
||||
|
||||
const hasRecentConversations = React.useMemo(() => {
|
||||
const askAiSource = props.state.collections[2];
|
||||
|
|
@ -111,14 +89,11 @@ export function SearchBox({
|
|||
const baseInputProps = props.getInputProps({
|
||||
inputElement: props.inputRef.current!,
|
||||
autoFocus: props.autoFocus,
|
||||
maxLength: MAX_QUERY_SIZE,
|
||||
});
|
||||
|
||||
const blockedKeys = new Set(['ArrowUp', 'ArrowDown', 'Enter']);
|
||||
const origOnKeyDown = baseInputProps.onKeyDown;
|
||||
const origOnChange = baseInputProps.onChange;
|
||||
const isAskAiStreaming = props.askAiStatus === 'streaming' || props.askAiStatus === 'submitted';
|
||||
const isKeywordSearchLoading = props.state.status === 'stalled';
|
||||
const renderMoreOptions = props.isAskAiActive && askAiState !== 'conversation-history';
|
||||
|
||||
// Use the thread depth error state passed from parent
|
||||
|
|
@ -159,7 +134,6 @@ export function SearchBox({
|
|||
* https://github.com/algolia/autocomplete/blob/next/packages/autocomplete-core/src/getDefaultProps.ts.
|
||||
*/
|
||||
const inputProps = {
|
||||
...baseInputProps,
|
||||
enterKeyHint: props.isAskAiActive ? ('enter' as const) : ('search' as const),
|
||||
onKeyDown: (e: React.KeyboardEvent<HTMLInputElement>): void => {
|
||||
// block these up, down, enter listeners when Ask AI is active
|
||||
|
|
@ -205,117 +179,75 @@ export function SearchBox({
|
|||
onAskAiToggle(false);
|
||||
}, [askAiState, isThreadDepthError, onAskAiToggle, setAskAiState, props]);
|
||||
|
||||
return (
|
||||
const leadingElement = props.isAskAiActive ? (
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={0}
|
||||
className="DocSearch-Action DocSearch-AskAi-Return"
|
||||
title={backToKeywordSearchButtonText}
|
||||
aria-label={backToKeywordSearchButtonAriaLabel}
|
||||
onClick={handleAskAiBackClick}
|
||||
>
|
||||
<BackIcon />
|
||||
</button>
|
||||
) : undefined;
|
||||
const inputOverlay = heading ? <ModalHeading heading={heading} shimmer={isAskAiStreaming} /> : null;
|
||||
const actionsBeforeClose = (
|
||||
<>
|
||||
<form
|
||||
className="DocSearch-Form"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
}}
|
||||
onReset={onReset}
|
||||
>
|
||||
{props.isAskAiActive ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={0}
|
||||
className="DocSearch-Action DocSearch-AskAi-Return"
|
||||
title={backToKeywordSearchButtonText}
|
||||
aria-label={backToKeywordSearchButtonAriaLabel}
|
||||
onClick={handleAskAiBackClick}
|
||||
>
|
||||
<BackIcon />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{isKeywordSearchLoading && (
|
||||
<div className="DocSearch-LoadingIndicator">
|
||||
<LoadingIcon />
|
||||
</div>
|
||||
)}
|
||||
{!isKeywordSearchLoading && (
|
||||
<label className="DocSearch-MagnifierLabel" {...props.getLabelProps()}>
|
||||
<SearchIcon />
|
||||
<span className="DocSearch-VisuallyHiddenForAccessibility">{searchInputLabel}</span>
|
||||
</label>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{heading && <ModalHeading heading={heading} shimmer={isAskAiStreaming} />}
|
||||
|
||||
<input
|
||||
className="DocSearch-Input"
|
||||
ref={props.inputRef}
|
||||
{...inputProps}
|
||||
placeholder={searchPlaceholder}
|
||||
hidden={Boolean(heading)}
|
||||
/>
|
||||
|
||||
<div className="DocSearch-Actions">
|
||||
<button
|
||||
className="DocSearch-Clear"
|
||||
type="reset"
|
||||
aria-label={clearButtonAriaLabel}
|
||||
hidden={!props.state.query}
|
||||
tabIndex={props.state.query ? 0 : -1}
|
||||
aria-hidden={!props.state.query ? 'true' : 'false'}
|
||||
>
|
||||
{clearButtonTitle}
|
||||
</button>
|
||||
|
||||
{props.state.query && <div className="DocSearch-Divider" />}
|
||||
|
||||
{isAskAiStreaming && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="DocSearch-Action DocSearch-StopStreaming"
|
||||
onClick={props.onStopAskAiStreaming}
|
||||
>
|
||||
<StopIcon />
|
||||
</button>
|
||||
|
||||
<div className="DocSearch-Divider" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{renderMoreOptions && (
|
||||
<>
|
||||
<Menu>
|
||||
<Menu.Trigger className="DocSearch-Action">
|
||||
<MoreVerticalIcon />
|
||||
</Menu.Trigger>
|
||||
<Menu.Content>
|
||||
<Menu.Item onClick={props.onNewConversation}>
|
||||
<NewConversationIcon />
|
||||
{startNewConversationText}
|
||||
</Menu.Item>
|
||||
{hasRecentConversations && (
|
||||
<Menu.Item onClick={props.onViewConversationHistory}>
|
||||
<ConversationHistoryIcon />
|
||||
{viewConversationHistoryText}
|
||||
</Menu.Item>
|
||||
)}
|
||||
</Menu.Content>
|
||||
</Menu>
|
||||
|
||||
<div className="DocSearch-Divider" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{isAskAiStreaming && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
title={closeButtonText}
|
||||
className="DocSearch-Action DocSearch-Close"
|
||||
aria-label={closeButtonAriaLabel}
|
||||
onClick={props.onClose}
|
||||
className="DocSearch-Action DocSearch-StopStreaming"
|
||||
onClick={props.onStopAskAiStreaming}
|
||||
>
|
||||
<CloseIcon />
|
||||
<StopIcon />
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="DocSearch-Divider" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{renderMoreOptions && (
|
||||
<>
|
||||
<Menu>
|
||||
<Menu.Trigger className="DocSearch-Action">
|
||||
<MoreVerticalIcon />
|
||||
</Menu.Trigger>
|
||||
<Menu.Content>
|
||||
<Menu.Item onClick={props.onNewConversation}>
|
||||
<NewConversationIcon />
|
||||
{startNewConversationText}
|
||||
</Menu.Item>
|
||||
{hasRecentConversations && (
|
||||
<Menu.Item onClick={props.onViewConversationHistory}>
|
||||
<ConversationHistoryIcon />
|
||||
{viewConversationHistoryText}
|
||||
</Menu.Item>
|
||||
)}
|
||||
</Menu.Content>
|
||||
</Menu>
|
||||
|
||||
<div className="DocSearch-Divider" />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<SearchBoxForm
|
||||
{...props}
|
||||
placeholder={searchPlaceholder}
|
||||
clearButtonTitle={clearButtonTitle}
|
||||
clearButtonAriaLabel={clearButtonAriaLabel}
|
||||
closeButtonText={closeButtonText}
|
||||
closeButtonAriaLabel={closeButtonAriaLabel}
|
||||
searchInputLabel={searchInputLabel}
|
||||
leadingElement={leadingElement}
|
||||
inputOverlay={inputOverlay}
|
||||
hideInput={Boolean(heading)}
|
||||
actionsBeforeClose={actionsBeforeClose}
|
||||
inputProps={inputProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
25
packages/docsearch-react/src/components/AskAiStartScreen.tsx
Normal file
25
packages/docsearch-react/src/components/AskAiStartScreen.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import React, { type JSX } from 'react';
|
||||
|
||||
import type { AskAiScreenStateProps } from '../AskAiScreenState';
|
||||
import type { InternalDocSearchHit } from '../types';
|
||||
|
||||
import type { RecentConversationsResultsTranslations } from './ui/RecentConversationsResults';
|
||||
import { RecentConversationsResults } from './ui/RecentConversationsResults';
|
||||
import type { StoredSearchesSectionsTranslations } from './ui/StoredSearchesSections';
|
||||
import { StoredSearchesSections } from './ui/StoredSearchesSections';
|
||||
|
||||
export type AskAiStartScreenTranslations = RecentConversationsResultsTranslations & StoredSearchesSectionsTranslations;
|
||||
|
||||
type AskAiStartScreenProps = Omit<AskAiScreenStateProps<InternalDocSearchHit>, 'translations'> & {
|
||||
hasCollections: boolean;
|
||||
translations?: AskAiStartScreenTranslations;
|
||||
};
|
||||
|
||||
export function AskAiStartScreen({ translations = {}, ...props }: AskAiStartScreenProps): JSX.Element | null {
|
||||
return (
|
||||
<div className="DocSearch-Dropdown-Container">
|
||||
<StoredSearchesSections {...props} translations={translations} />
|
||||
<RecentConversationsResults {...props} translations={translations} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
51
packages/docsearch-react/src/components/KeywordSearchBox.tsx
Normal file
51
packages/docsearch-react/src/components/KeywordSearchBox.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import type { AutocompleteApi, AutocompleteState } from '@algolia/autocomplete-core';
|
||||
import React, { type JSX, type RefObject } from 'react';
|
||||
|
||||
import type { InternalDocSearchHit } from '../types';
|
||||
|
||||
import { SearchBoxForm } from './ui/SearchBoxForm';
|
||||
|
||||
export type KeywordSearchBoxTranslations = Partial<{
|
||||
clearButtonTitle: string;
|
||||
clearButtonAriaLabel: string;
|
||||
closeButtonText: string;
|
||||
closeButtonAriaLabel: string;
|
||||
placeholderText: string;
|
||||
enterKeyHint: string;
|
||||
searchInputLabel: string;
|
||||
}>;
|
||||
|
||||
interface KeywordSearchBoxProps
|
||||
extends AutocompleteApi<InternalDocSearchHit, React.FormEvent, React.MouseEvent, React.KeyboardEvent> {
|
||||
state: AutocompleteState<InternalDocSearchHit>;
|
||||
autoFocus: boolean;
|
||||
inputRef: RefObject<HTMLInputElement | null>;
|
||||
onClose: () => void;
|
||||
placeholder: string;
|
||||
isFromSelection: boolean;
|
||||
translations?: KeywordSearchBoxTranslations;
|
||||
}
|
||||
|
||||
export function KeywordSearchBox({ translations = {}, ...props }: KeywordSearchBoxProps): JSX.Element {
|
||||
const {
|
||||
clearButtonTitle = 'Clear',
|
||||
clearButtonAriaLabel = 'Clear the query',
|
||||
closeButtonText = 'Close',
|
||||
closeButtonAriaLabel = 'Close',
|
||||
searchInputLabel = 'Search',
|
||||
} = translations;
|
||||
|
||||
return (
|
||||
<SearchBoxForm
|
||||
{...props}
|
||||
clearButtonTitle={clearButtonTitle}
|
||||
clearButtonAriaLabel={clearButtonAriaLabel}
|
||||
closeButtonText={closeButtonText}
|
||||
closeButtonAriaLabel={closeButtonAriaLabel}
|
||||
searchInputLabel={searchInputLabel}
|
||||
inputProps={{
|
||||
enterKeyHint: 'search',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
import React, { type JSX } from 'react';
|
||||
|
||||
import type { ScreenStateProps } from '../ScreenState';
|
||||
import type { InternalDocSearchHit } from '../types';
|
||||
|
||||
import type { StoredSearchesSectionsTranslations } from './ui/StoredSearchesSections';
|
||||
import { StoredSearchesSections } from './ui/StoredSearchesSections';
|
||||
|
||||
export type KeywordStartScreenTranslations = StoredSearchesSectionsTranslations;
|
||||
|
||||
type KeywordStartScreenProps = Omit<ScreenStateProps<InternalDocSearchHit>, 'translations'> & {
|
||||
hasCollections: boolean;
|
||||
translations?: KeywordStartScreenTranslations;
|
||||
};
|
||||
|
||||
export function KeywordStartScreen({ translations = {}, ...props }: KeywordStartScreenProps): JSX.Element | null {
|
||||
return (
|
||||
<div className="DocSearch-Dropdown-Container">
|
||||
<StoredSearchesSections {...props} translations={translations} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
73
packages/docsearch-react/src/components/ui/ModalShell.tsx
Normal file
73
packages/docsearch-react/src/components/ui/ModalShell.tsx
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import type { AutocompleteApi } from '@algolia/autocomplete-core';
|
||||
import React, { type JSX } from 'react';
|
||||
|
||||
import type { DocSearchState, InternalDocSearchHit } from '../../types';
|
||||
|
||||
export type ModalShellProps = {
|
||||
state: DocSearchState<InternalDocSearchHit>;
|
||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||
modalRef: React.RefObject<HTMLDivElement | null>;
|
||||
formElementRef: React.RefObject<HTMLDivElement | null>;
|
||||
dropdownRef: React.RefObject<HTMLDivElement | null>;
|
||||
getRootProps: AutocompleteApi<
|
||||
InternalDocSearchHit,
|
||||
React.FormEvent<HTMLFormElement>,
|
||||
React.MouseEvent,
|
||||
React.KeyboardEvent
|
||||
>['getRootProps'];
|
||||
onClose: () => void;
|
||||
showDropdown: boolean;
|
||||
searchBox: React.ReactNode;
|
||||
screenState: React.ReactNode;
|
||||
footer: React.ReactNode;
|
||||
};
|
||||
|
||||
export function ModalShell({
|
||||
state,
|
||||
containerRef,
|
||||
modalRef,
|
||||
formElementRef,
|
||||
dropdownRef,
|
||||
getRootProps,
|
||||
onClose,
|
||||
showDropdown,
|
||||
searchBox,
|
||||
screenState,
|
||||
footer,
|
||||
}: ModalShellProps): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
{...getRootProps({ 'aria-expanded': true })}
|
||||
className={[
|
||||
'DocSearch',
|
||||
'DocSearch-Container',
|
||||
state.status === 'stalled' && 'DocSearch-Container--Stalled',
|
||||
state.status === 'error' && 'DocSearch-Container--Errored',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onMouseDown={(event) => {
|
||||
if (event.target === event.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="DocSearch-Modal" ref={modalRef}>
|
||||
<header className="DocSearch-SearchBar" ref={formElementRef}>
|
||||
{searchBox}
|
||||
</header>
|
||||
|
||||
{showDropdown && (
|
||||
<div className="DocSearch-Dropdown" ref={dropdownRef}>
|
||||
{screenState}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<footer className="DocSearch-Footer">{footer}</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
import React, { type JSX } from 'react';
|
||||
|
||||
import type { AskAiScreenStateProps } from '../../AskAiScreenState';
|
||||
import { CloseIcon, SparklesIcon } from '../../icons';
|
||||
import { Results } from '../../Results';
|
||||
import type { InternalDocSearchHit } from '../../types';
|
||||
|
||||
export type RecentConversationsResultsTranslations = Partial<{
|
||||
recentConversationsTitle: string;
|
||||
removeRecentConversationButtonTitle: string;
|
||||
}>;
|
||||
|
||||
type RecentConversationsResultsProps = Omit<AskAiScreenStateProps<InternalDocSearchHit>, 'translations'> & {
|
||||
translations?: RecentConversationsResultsTranslations;
|
||||
};
|
||||
|
||||
export function RecentConversationsResults({
|
||||
translations = {},
|
||||
...props
|
||||
}: RecentConversationsResultsProps): JSX.Element {
|
||||
const {
|
||||
recentConversationsTitle = 'Recent conversations',
|
||||
removeRecentConversationButtonTitle = 'Remove this conversation from history',
|
||||
} = translations;
|
||||
|
||||
return (
|
||||
<Results
|
||||
{...props}
|
||||
title={recentConversationsTitle}
|
||||
collection={props.state.collections[2]}
|
||||
renderIcon={() => (
|
||||
<div className="DocSearch-Hit-icon">
|
||||
<SparklesIcon />
|
||||
</div>
|
||||
)}
|
||||
renderAction={({ item }) => (
|
||||
<div className="DocSearch-Hit-action">
|
||||
<button
|
||||
className="DocSearch-Hit-action-button"
|
||||
title={removeRecentConversationButtonTitle}
|
||||
type="submit"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
props.conversations.remove(item);
|
||||
props.refresh();
|
||||
}}
|
||||
>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
130
packages/docsearch-react/src/components/ui/SearchBoxForm.tsx
Normal file
130
packages/docsearch-react/src/components/ui/SearchBoxForm.tsx
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
import type { AutocompleteApi, AutocompleteState } from '@algolia/autocomplete-core';
|
||||
import React, { type JSX, type RefObject } from 'react';
|
||||
|
||||
import { MAX_QUERY_SIZE } from '../../constants';
|
||||
import { CloseIcon, LoadingIcon, SearchIcon } from '../../icons';
|
||||
import type { InternalDocSearchHit } from '../../types';
|
||||
|
||||
interface SearchBoxFormProps
|
||||
extends AutocompleteApi<InternalDocSearchHit, React.FormEvent, React.MouseEvent, React.KeyboardEvent> {
|
||||
state: AutocompleteState<InternalDocSearchHit>;
|
||||
autoFocus: boolean;
|
||||
inputRef: RefObject<HTMLInputElement | null>;
|
||||
isFromSelection: boolean;
|
||||
placeholder: string;
|
||||
onClose: () => void;
|
||||
clearButtonTitle: string;
|
||||
clearButtonAriaLabel: string;
|
||||
closeButtonText: string;
|
||||
closeButtonAriaLabel: string;
|
||||
searchInputLabel: string;
|
||||
inputProps?: Partial<React.InputHTMLAttributes<HTMLInputElement>>;
|
||||
leadingElement?: React.ReactNode;
|
||||
inputOverlay?: React.ReactNode;
|
||||
actionsBeforeClose?: React.ReactNode;
|
||||
hideInput?: boolean;
|
||||
}
|
||||
|
||||
export function SearchBoxForm({
|
||||
actionsBeforeClose,
|
||||
autoFocus,
|
||||
clearButtonAriaLabel,
|
||||
clearButtonTitle,
|
||||
closeButtonAriaLabel,
|
||||
closeButtonText,
|
||||
hideInput,
|
||||
inputOverlay,
|
||||
inputProps,
|
||||
inputRef,
|
||||
isFromSelection,
|
||||
leadingElement,
|
||||
onClose,
|
||||
placeholder,
|
||||
searchInputLabel,
|
||||
state,
|
||||
...autocomplete
|
||||
}: SearchBoxFormProps): JSX.Element {
|
||||
const { onReset } = autocomplete.getFormProps({
|
||||
inputElement: inputRef.current,
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (autoFocus && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}, [autoFocus, inputRef]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isFromSelection && inputRef.current) {
|
||||
inputRef.current.select();
|
||||
}
|
||||
}, [isFromSelection, inputRef]);
|
||||
|
||||
const baseInputProps = autocomplete.getInputProps({
|
||||
inputElement: inputRef.current!,
|
||||
autoFocus,
|
||||
maxLength: MAX_QUERY_SIZE,
|
||||
});
|
||||
|
||||
const isKeywordSearchLoading = state.status === 'stalled';
|
||||
|
||||
return (
|
||||
<form
|
||||
className="DocSearch-Form"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
}}
|
||||
onReset={onReset}
|
||||
>
|
||||
{leadingElement ||
|
||||
(isKeywordSearchLoading ? (
|
||||
<div className="DocSearch-LoadingIndicator">
|
||||
<LoadingIcon />
|
||||
</div>
|
||||
) : (
|
||||
<label className="DocSearch-MagnifierLabel" {...autocomplete.getLabelProps()}>
|
||||
<SearchIcon />
|
||||
<span className="DocSearch-VisuallyHiddenForAccessibility">{searchInputLabel}</span>
|
||||
</label>
|
||||
))}
|
||||
|
||||
{inputOverlay}
|
||||
|
||||
<input
|
||||
className="DocSearch-Input"
|
||||
ref={inputRef}
|
||||
{...baseInputProps}
|
||||
{...inputProps}
|
||||
placeholder={placeholder}
|
||||
hidden={hideInput}
|
||||
/>
|
||||
|
||||
<div className="DocSearch-Actions">
|
||||
<button
|
||||
className="DocSearch-Clear"
|
||||
type="reset"
|
||||
aria-label={clearButtonAriaLabel}
|
||||
hidden={!state.query}
|
||||
tabIndex={state.query ? 0 : -1}
|
||||
aria-hidden={!state.query ? 'true' : 'false'}
|
||||
>
|
||||
{clearButtonTitle}
|
||||
</button>
|
||||
|
||||
{state.query && <div className="DocSearch-Divider" />}
|
||||
|
||||
{actionsBeforeClose}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
title={closeButtonText}
|
||||
className="DocSearch-Action DocSearch-Close"
|
||||
aria-label={closeButtonAriaLabel}
|
||||
onClick={onClose}
|
||||
>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,43 +1,39 @@
|
|||
import React, { type JSX } from 'react';
|
||||
|
||||
import { RecentIcon, CloseIcon, StarIcon, SparklesIcon } from './icons';
|
||||
import { Results } from './Results';
|
||||
import type { ScreenStateProps } from './ScreenState';
|
||||
import type { InternalDocSearchHit } from './types';
|
||||
import { CloseIcon, RecentIcon, StarIcon } from '../../icons';
|
||||
import { Results } from '../../Results';
|
||||
import type { ScreenStateProps } from '../../ScreenState';
|
||||
import type { InternalDocSearchHit } from '../../types';
|
||||
|
||||
export type StartScreenTranslations = Partial<{
|
||||
export type StoredSearchesSectionsTranslations = Partial<{
|
||||
recentSearchesTitle: string;
|
||||
noRecentSearchesText: string;
|
||||
saveRecentSearchButtonTitle: string;
|
||||
removeRecentSearchButtonTitle: string;
|
||||
favoriteSearchesTitle: string;
|
||||
removeFavoriteSearchButtonTitle: string;
|
||||
recentConversationsTitle: string;
|
||||
removeRecentConversationButtonTitle: string;
|
||||
}>;
|
||||
|
||||
type StartScreenProps = Omit<ScreenStateProps<InternalDocSearchHit>, 'translations'> & {
|
||||
hasCollections: boolean;
|
||||
translations?: StartScreenTranslations;
|
||||
type StoredSearchesSectionsProps = Omit<ScreenStateProps<InternalDocSearchHit>, 'translations'> & {
|
||||
translations?: StoredSearchesSectionsTranslations;
|
||||
};
|
||||
|
||||
export function StartScreen({ translations = {}, ...props }: StartScreenProps): JSX.Element | null {
|
||||
export function StoredSearchesSections({ translations = {}, ...props }: StoredSearchesSectionsProps): JSX.Element {
|
||||
const {
|
||||
recentSearchesTitle = 'Recent',
|
||||
saveRecentSearchButtonTitle = 'Save this search',
|
||||
removeRecentSearchButtonTitle = 'Remove this search from history',
|
||||
favoriteSearchesTitle = 'Favorite',
|
||||
removeFavoriteSearchButtonTitle = 'Remove this search from favorites',
|
||||
recentConversationsTitle = 'Recent conversations',
|
||||
removeRecentConversationButtonTitle = 'Remove this conversation from history',
|
||||
} = translations;
|
||||
const recentSearchesCollection = props.state.collections[0];
|
||||
|
||||
return (
|
||||
<div className="DocSearch-Dropdown-Container">
|
||||
<>
|
||||
<Results
|
||||
{...props}
|
||||
title={recentSearchesTitle}
|
||||
collection={props.state.collections[0]}
|
||||
collection={recentSearchesCollection}
|
||||
renderIcon={() => (
|
||||
<div className="DocSearch-Hit-icon">
|
||||
<RecentIcon />
|
||||
|
|
@ -107,34 +103,6 @@ export function StartScreen({ translations = {}, ...props }: StartScreenProps):
|
|||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Results
|
||||
{...props}
|
||||
title={recentConversationsTitle}
|
||||
collection={props.state.collections[2]}
|
||||
renderIcon={() => (
|
||||
<div className="DocSearch-Hit-icon">
|
||||
<SparklesIcon />
|
||||
</div>
|
||||
)}
|
||||
renderAction={({ item }) => (
|
||||
<div className="DocSearch-Hit-action">
|
||||
<button
|
||||
className="DocSearch-Hit-action-button"
|
||||
title={removeRecentConversationButtonTitle}
|
||||
type="submit"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
props.conversations.remove(item);
|
||||
props.refresh();
|
||||
}}
|
||||
>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
27
packages/docsearch-react/src/hooks/useDocSearchInsights.ts
Normal file
27
packages/docsearch-react/src/hooks/useDocSearchInsights.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import type { AlgoliaInsightsHit } from '@algolia/autocomplete-core';
|
||||
import React from 'react';
|
||||
|
||||
import type { DocSearchState, InternalDocSearchHit } from '../types';
|
||||
|
||||
export function useSendItemClickEvent(
|
||||
state: DocSearchState<InternalDocSearchHit>,
|
||||
): (item: InternalDocSearchHit) => void {
|
||||
return React.useCallback(
|
||||
(item: InternalDocSearchHit): void => {
|
||||
if (!state.context.algoliaInsightsPlugin || !item.__autocomplete_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const insightsItem = item as AlgoliaInsightsHit;
|
||||
|
||||
state.context.algoliaInsightsPlugin.insights.clickedObjectIDsAfterSearch({
|
||||
eventName: 'Item Selected',
|
||||
index: insightsItem.__autocomplete_indexName,
|
||||
items: [insightsItem],
|
||||
positions: [item.__autocomplete_id],
|
||||
queryID: insightsItem.__autocomplete_queryID,
|
||||
});
|
||||
},
|
||||
[state.context.algoliaInsightsPlugin],
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
import { useTheme } from '@docsearch/core/useTheme';
|
||||
import React from 'react';
|
||||
|
||||
import type { DocSearchTheme } from '../types';
|
||||
import { manageLocalStorageQuota } from '../utils/storage';
|
||||
|
||||
export function useDocSearchModalEffects({
|
||||
initialScrollY,
|
||||
modalRef,
|
||||
snippetLength,
|
||||
theme,
|
||||
}: {
|
||||
initialScrollY: number;
|
||||
modalRef: React.RefObject<HTMLDivElement | null>;
|
||||
snippetLength: React.MutableRefObject<number>;
|
||||
theme?: DocSearchTheme;
|
||||
}): void {
|
||||
useTheme({ theme });
|
||||
|
||||
React.useEffect(() => {
|
||||
document.body.classList.add('DocSearch--active');
|
||||
|
||||
return (): void => {
|
||||
document.body.classList.remove('DocSearch--active');
|
||||
window.scrollTo?.(0, initialScrollY);
|
||||
};
|
||||
}, [initialScrollY]);
|
||||
|
||||
React.useEffect(() => {
|
||||
manageLocalStorageQuota();
|
||||
}, []);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
const scrollBarWidth = window.innerWidth - document.body.clientWidth;
|
||||
document.body.style.marginInlineEnd = `${scrollBarWidth}px`;
|
||||
|
||||
return (): void => {
|
||||
document.body.style.marginInlineEnd = '0px';
|
||||
};
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (window.matchMedia('(max-width: 768px)').matches) {
|
||||
snippetLength.current = 5;
|
||||
}
|
||||
}, [snippetLength]);
|
||||
|
||||
React.useEffect(() => {
|
||||
function setFullViewportHeight(): void {
|
||||
if (modalRef.current) {
|
||||
const vh = window.innerHeight * 0.01;
|
||||
modalRef.current.style.setProperty('--docsearch-vh', `${vh}px`);
|
||||
}
|
||||
}
|
||||
|
||||
setFullViewportHeight();
|
||||
window.addEventListener('resize', setFullViewportHeight);
|
||||
|
||||
return (): void => {
|
||||
window.removeEventListener('resize', setFullViewportHeight);
|
||||
};
|
||||
}, [modalRef]);
|
||||
}
|
||||
15
packages/docsearch-react/src/hooks/useInitialModalQuery.ts
Normal file
15
packages/docsearch-react/src/hooks/useInitialModalQuery.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import React from 'react';
|
||||
|
||||
import { MAX_QUERY_SIZE } from '../constants';
|
||||
|
||||
export function useInitialModalQuery(initialQueryFromProp: string): {
|
||||
initialQuery: string;
|
||||
initialQueryFromSelection: string;
|
||||
} {
|
||||
const initialQueryFromSelection = React.useRef(
|
||||
typeof window !== 'undefined' ? window.getSelection()!.toString().slice(0, MAX_QUERY_SIZE) : '',
|
||||
).current;
|
||||
const initialQuery = React.useRef(initialQueryFromProp || initialQueryFromSelection).current;
|
||||
|
||||
return { initialQuery, initialQueryFromSelection };
|
||||
}
|
||||
44
packages/docsearch-react/src/hooks/useModalEnvironment.ts
Normal file
44
packages/docsearch-react/src/hooks/useModalEnvironment.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import type { AutocompleteApi } from '@algolia/autocomplete-core';
|
||||
import type React from 'react';
|
||||
|
||||
import type { DocSearchTheme, InternalDocSearchHit } from '../types';
|
||||
import { useTouchEvents } from '../useTouchEvents';
|
||||
import { useTrapFocus } from '../useTrapFocus';
|
||||
|
||||
import { useDocSearchModalEffects } from './useDocSearchModalEffects';
|
||||
|
||||
export function useModalEnvironment({
|
||||
getEnvironmentProps,
|
||||
containerRef,
|
||||
dropdownRef,
|
||||
formElementRef,
|
||||
inputRef,
|
||||
initialScrollY,
|
||||
modalRef,
|
||||
snippetLength,
|
||||
theme,
|
||||
}: {
|
||||
getEnvironmentProps: AutocompleteApi<
|
||||
InternalDocSearchHit,
|
||||
React.FormEvent<HTMLFormElement>,
|
||||
React.MouseEvent,
|
||||
React.KeyboardEvent
|
||||
>['getEnvironmentProps'];
|
||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||
dropdownRef: React.RefObject<HTMLDivElement | null>;
|
||||
formElementRef: React.RefObject<HTMLDivElement | null>;
|
||||
inputRef: React.RefObject<HTMLInputElement | null>;
|
||||
initialScrollY: number;
|
||||
modalRef: React.RefObject<HTMLDivElement | null>;
|
||||
snippetLength: React.MutableRefObject<number>;
|
||||
theme?: DocSearchTheme;
|
||||
}): void {
|
||||
useTouchEvents({
|
||||
getEnvironmentProps,
|
||||
panelElement: dropdownRef.current,
|
||||
formElement: formElementRef.current,
|
||||
inputElement: inputRef.current,
|
||||
});
|
||||
useTrapFocus({ container: containerRef.current });
|
||||
useDocSearchModalEffects({ initialScrollY, modalRef, snippetLength, theme });
|
||||
}
|
||||
26
packages/docsearch-react/src/hooks/useModalRefs.ts
Normal file
26
packages/docsearch-react/src/hooks/useModalRefs.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import React from 'react';
|
||||
|
||||
export function useModalRefs(): {
|
||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||
modalRef: React.RefObject<HTMLDivElement | null>;
|
||||
formElementRef: React.RefObject<HTMLDivElement | null>;
|
||||
dropdownRef: React.RefObject<HTMLDivElement | null>;
|
||||
inputRef: React.RefObject<HTMLInputElement | null>;
|
||||
snippetLength: React.MutableRefObject<number>;
|
||||
} {
|
||||
const containerRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const modalRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const formElementRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const dropdownRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const inputRef = React.useRef<HTMLInputElement | null>(null);
|
||||
const snippetLength = React.useRef<number>(15);
|
||||
|
||||
return {
|
||||
containerRef,
|
||||
modalRef,
|
||||
formElementRef,
|
||||
dropdownRef,
|
||||
inputRef,
|
||||
snippetLength,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
import React from 'react';
|
||||
|
||||
export function useRefreshOnInitialQuery({
|
||||
initialQuery,
|
||||
inputRef,
|
||||
refresh,
|
||||
}: {
|
||||
initialQuery: string;
|
||||
inputRef: React.RefObject<HTMLInputElement | null>;
|
||||
refresh: () => void;
|
||||
}): void {
|
||||
React.useEffect(() => {
|
||||
if (initialQuery.length > 0) {
|
||||
refresh();
|
||||
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}
|
||||
}, [initialQuery, inputRef, refresh]);
|
||||
}
|
||||
41
packages/docsearch-react/src/hooks/useSaveRecentSearch.ts
Normal file
41
packages/docsearch-react/src/hooks/useSaveRecentSearch.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import React from 'react';
|
||||
|
||||
import type { StoredSearchPlugin } from '../stored-searches';
|
||||
import type { InternalDocSearchHit, StoredDocSearchHit } from '../types';
|
||||
|
||||
function createSyntheticParent(item: InternalDocSearchHit): InternalDocSearchHit {
|
||||
const hierarchy = item.hierarchy;
|
||||
const levels = ['lvl6', 'lvl5', 'lvl4', 'lvl3', 'lvl2', 'lvl1', 'lvl0'] as const;
|
||||
const deepestLevel = levels.find((level) => hierarchy[level]);
|
||||
|
||||
return {
|
||||
...item,
|
||||
type: deepestLevel || 'lvl0',
|
||||
content: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function useSaveRecentSearch({
|
||||
favoriteSearches,
|
||||
recentSearches,
|
||||
disableUserPersonalization,
|
||||
}: {
|
||||
favoriteSearches: StoredSearchPlugin<StoredDocSearchHit>;
|
||||
recentSearches: StoredSearchPlugin<StoredDocSearchHit>;
|
||||
disableUserPersonalization: boolean;
|
||||
}): (item: InternalDocSearchHit) => void {
|
||||
return React.useCallback(
|
||||
function saveRecentSearch(item: InternalDocSearchHit): void {
|
||||
if (disableUserPersonalization) {
|
||||
return;
|
||||
}
|
||||
|
||||
const search = item.type === 'content' ? item.__docsearch_parent || createSyntheticParent(item) : item;
|
||||
|
||||
if (search && favoriteSearches.getAll().findIndex((x) => x.objectID === search.objectID) === -1) {
|
||||
recentSearches.add(search);
|
||||
}
|
||||
},
|
||||
[favoriteSearches, recentSearches, disableUserPersonalization],
|
||||
);
|
||||
}
|
||||
33
packages/docsearch-react/src/hooks/useStoredDocSearches.ts
Normal file
33
packages/docsearch-react/src/hooks/useStoredDocSearches.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import React from 'react';
|
||||
|
||||
import { createStoredSearches } from '../stored-searches';
|
||||
import type { StoredDocSearchHit } from '../types';
|
||||
|
||||
export function useStoredDocSearches({
|
||||
defaultIndexName,
|
||||
recentSearchesLimit,
|
||||
recentSearchesWithFavoritesLimit,
|
||||
}: {
|
||||
defaultIndexName: string;
|
||||
recentSearchesLimit: number;
|
||||
recentSearchesWithFavoritesLimit: number;
|
||||
}): {
|
||||
favoriteSearches: ReturnType<typeof createStoredSearches<StoredDocSearchHit>>;
|
||||
recentSearches: ReturnType<typeof createStoredSearches<StoredDocSearchHit>>;
|
||||
} {
|
||||
const favoriteSearches = React.useRef(
|
||||
createStoredSearches<StoredDocSearchHit>({
|
||||
key: `__DOCSEARCH_FAVORITE_SEARCHES__${defaultIndexName}`,
|
||||
limit: 10,
|
||||
}),
|
||||
).current;
|
||||
|
||||
const recentSearches = React.useRef(
|
||||
createStoredSearches<StoredDocSearchHit>({
|
||||
key: `__DOCSEARCH_RECENT_SEARCHES__${defaultIndexName}`,
|
||||
limit: favoriteSearches.getAll().length === 0 ? recentSearchesLimit : recentSearchesWithFavoritesLimit,
|
||||
}),
|
||||
).current;
|
||||
|
||||
return { favoriteSearches, recentSearches };
|
||||
}
|
||||
|
|
@ -1,4 +1,6 @@
|
|||
export * from './DocSearch';
|
||||
export * from './DocSearchAI';
|
||||
export * from './DocSearchAskAiModal';
|
||||
export * from './DocSearchButton';
|
||||
export * from './DocSearchModal';
|
||||
export * from './useDocSearchKeyboardEvents';
|
||||
|
|
|
|||
98
packages/docsearch-react/src/utils/createAskAiSources.ts
Normal file
98
packages/docsearch-react/src/utils/createAskAiSources.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import type { AutocompleteSource } from '@algolia/autocomplete-core';
|
||||
|
||||
import type { InternalDocSearchHit } from '../types';
|
||||
import type { AIMessage } from '../types/AskiAi';
|
||||
|
||||
export function buildRecentConversationSources({
|
||||
conversations,
|
||||
disableUserPersonalization,
|
||||
setMessages,
|
||||
onAskAiToggle,
|
||||
}: {
|
||||
conversations: { getAll: () => unknown[] };
|
||||
disableUserPersonalization: boolean;
|
||||
setMessages: (messages: AIMessage[]) => void;
|
||||
onAskAiToggle: (toggle: boolean) => void;
|
||||
}): Array<AutocompleteSource<InternalDocSearchHit & { messages?: AIMessage[] }>> {
|
||||
return [
|
||||
{
|
||||
sourceId: 'recentConversations',
|
||||
getItems(): InternalDocSearchHit[] {
|
||||
if (disableUserPersonalization) {
|
||||
return [];
|
||||
}
|
||||
return conversations.getAll() as unknown as InternalDocSearchHit[];
|
||||
},
|
||||
onSelect({ item }): void {
|
||||
if (item.messages) {
|
||||
setMessages(item.messages);
|
||||
onAskAiToggle(true);
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function buildAskAiActionSources({
|
||||
query,
|
||||
handleSelectAskAiQuestion,
|
||||
}: {
|
||||
query: string;
|
||||
handleSelectAskAiQuestion: (toggle: boolean, query: string) => void;
|
||||
}): Array<AutocompleteSource<InternalDocSearchHit>> {
|
||||
const emptyHierarchyHighlightResult = {
|
||||
lvl0: { value: '', matchLevel: 'none', matchedWords: [] },
|
||||
lvl1: { value: '', 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: [] },
|
||||
} satisfies InternalDocSearchHit['_highlightResult']['hierarchy'];
|
||||
const emptyHighlightResult: InternalDocSearchHit['_highlightResult'] = {
|
||||
content: { value: '', matchLevel: 'none', matchedWords: [] },
|
||||
hierarchy: emptyHierarchyHighlightResult,
|
||||
hierarchy_camel: [],
|
||||
};
|
||||
const emptySnippetResult: InternalDocSearchHit['_snippetResult'] = {
|
||||
content: { value: '', matchLevel: 'none' },
|
||||
hierarchy: emptyHierarchyHighlightResult,
|
||||
hierarchy_camel: [],
|
||||
};
|
||||
|
||||
return [
|
||||
{
|
||||
sourceId: 'askAI',
|
||||
getItems(): InternalDocSearchHit[] {
|
||||
return [
|
||||
{
|
||||
type: 'askAI',
|
||||
query,
|
||||
url_without_anchor: '',
|
||||
objectID: 'ask-ai-button',
|
||||
content: null,
|
||||
url: '',
|
||||
anchor: null,
|
||||
hierarchy: {
|
||||
lvl0: 'Ask AI',
|
||||
lvl1: query,
|
||||
lvl2: null,
|
||||
lvl3: null,
|
||||
lvl4: null,
|
||||
lvl5: null,
|
||||
lvl6: null,
|
||||
},
|
||||
_highlightResult: emptyHighlightResult,
|
||||
_snippetResult: emptySnippetResult,
|
||||
__docsearch_parent: null,
|
||||
},
|
||||
];
|
||||
},
|
||||
onSelect({ item }): void {
|
||||
if (item.type === 'askAI' && item.query) {
|
||||
handleSelectAskAiQuestion(true, item.query);
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
218
packages/docsearch-react/src/utils/createDocSearchSources.ts
Normal file
218
packages/docsearch-react/src/utils/createDocSearchSources.ts
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
import type { AutocompleteSource, AutocompleteState } from '@algolia/autocomplete-core';
|
||||
import type { SearchResponse } from 'algoliasearch/lite';
|
||||
import type React from 'react';
|
||||
|
||||
import type { DocSearchIndex, DocSearchProps } from '../DocSearch';
|
||||
import type { DocSearchHit, DocSearchState, InternalDocSearchHit } from '../types';
|
||||
import type { useSearchClient } from '../useSearchClient';
|
||||
|
||||
import { groupBy } from './groupBy';
|
||||
import { identity } from './identity';
|
||||
import { isModifierEvent } from './isModifierEvent';
|
||||
import { removeHighlightTags } from './removeHighlightTags';
|
||||
|
||||
export type BuildQuerySourcesState = Pick<AutocompleteState<InternalDocSearchHit>, 'context'>;
|
||||
|
||||
export type StoredSearchesLike<TItem> = {
|
||||
getAll: () => TItem[];
|
||||
};
|
||||
|
||||
export function buildNoQuerySources({
|
||||
recentSearches,
|
||||
favoriteSearches,
|
||||
saveRecentSearch,
|
||||
onClose,
|
||||
disableUserPersonalization,
|
||||
}: {
|
||||
recentSearches: StoredSearchesLike<unknown>;
|
||||
favoriteSearches: StoredSearchesLike<unknown>;
|
||||
saveRecentSearch: (item: InternalDocSearchHit) => void;
|
||||
onClose: () => void;
|
||||
disableUserPersonalization: boolean;
|
||||
}): Array<AutocompleteSource<InternalDocSearchHit>> {
|
||||
if (disableUserPersonalization) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
sourceId: 'recentSearches',
|
||||
onSelect({ item, event }): void {
|
||||
saveRecentSearch(item);
|
||||
if (!isModifierEvent(event)) {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
getItemUrl({ item }): string {
|
||||
return item.url;
|
||||
},
|
||||
getItems(): InternalDocSearchHit[] {
|
||||
return recentSearches.getAll() as InternalDocSearchHit[];
|
||||
},
|
||||
},
|
||||
{
|
||||
sourceId: 'favoriteSearches',
|
||||
onSelect({ item, event }): void {
|
||||
saveRecentSearch(item);
|
||||
if (!isModifierEvent(event)) {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
getItemUrl({ item }): string {
|
||||
return item.url;
|
||||
},
|
||||
getItems(): InternalDocSearchHit[] {
|
||||
return favoriteSearches.getAll() as InternalDocSearchHit[];
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export async function buildQuerySources({
|
||||
query,
|
||||
state: sourcesState,
|
||||
setContext,
|
||||
setStatus,
|
||||
searchClient,
|
||||
indexes,
|
||||
snippetLength,
|
||||
insights,
|
||||
appId,
|
||||
apiKey,
|
||||
maxResultsPerGroup,
|
||||
transformItems = identity,
|
||||
saveRecentSearch,
|
||||
onClose,
|
||||
}: {
|
||||
query: string;
|
||||
state: BuildQuerySourcesState;
|
||||
setContext: (context: Partial<DocSearchState<InternalDocSearchHit>['context']>) => void;
|
||||
setStatus: (status: DocSearchState<InternalDocSearchHit>['status']) => void;
|
||||
searchClient: ReturnType<typeof useSearchClient>;
|
||||
indexes: DocSearchIndex[];
|
||||
snippetLength: React.MutableRefObject<number>;
|
||||
insights: boolean;
|
||||
appId?: string;
|
||||
apiKey?: string;
|
||||
maxResultsPerGroup?: number;
|
||||
transformItems?: DocSearchProps['transformItems'];
|
||||
saveRecentSearch: (item: InternalDocSearchHit) => void;
|
||||
onClose: () => void;
|
||||
}): Promise<Array<AutocompleteSource<InternalDocSearchHit>>> {
|
||||
const insightsActive = insights;
|
||||
|
||||
try {
|
||||
const { results } = await searchClient.search<DocSearchHit>({
|
||||
requests: indexes.map((index) => {
|
||||
const indexName = index.name;
|
||||
const searchParams = index.searchParameters;
|
||||
|
||||
return {
|
||||
query,
|
||||
indexName,
|
||||
attributesToRetrieve: searchParams?.attributesToRetrieve ?? [
|
||||
'hierarchy.lvl0',
|
||||
'hierarchy.lvl1',
|
||||
'hierarchy.lvl2',
|
||||
'hierarchy.lvl3',
|
||||
'hierarchy.lvl4',
|
||||
'hierarchy.lvl5',
|
||||
'hierarchy.lvl6',
|
||||
'content',
|
||||
'type',
|
||||
'url',
|
||||
],
|
||||
attributesToSnippet: searchParams?.attributesToSnippet ?? [
|
||||
`hierarchy.lvl1:${snippetLength.current}`,
|
||||
`hierarchy.lvl2:${snippetLength.current}`,
|
||||
`hierarchy.lvl3:${snippetLength.current}`,
|
||||
`hierarchy.lvl4:${snippetLength.current}`,
|
||||
`hierarchy.lvl5:${snippetLength.current}`,
|
||||
`hierarchy.lvl6:${snippetLength.current}`,
|
||||
`content:${snippetLength.current}`,
|
||||
],
|
||||
snippetEllipsisText: searchParams?.snippetEllipsisText ?? '…',
|
||||
highlightPreTag: searchParams?.highlightPreTag ?? '<mark>',
|
||||
highlightPostTag: searchParams?.highlightPostTag ?? '</mark>',
|
||||
hitsPerPage: searchParams?.hitsPerPage ?? 20,
|
||||
clickAnalytics: searchParams?.clickAnalytics ?? insightsActive,
|
||||
...(searchParams ?? {}),
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
return results.flatMap((res) => {
|
||||
const result = res as SearchResponse<DocSearchHit>;
|
||||
const { hits, nbHits } = result;
|
||||
const transformedHits = transformItems(hits);
|
||||
const sources = groupBy<DocSearchHit>(transformedHits, (hit) => removeHighlightTags(hit), maxResultsPerGroup);
|
||||
|
||||
if ((sourcesState.context.searchSuggestions as unknown[]).length < Object.keys(sources).length) {
|
||||
setContext({
|
||||
searchSuggestions: {
|
||||
...(sourcesState.context.searchSuggestions ?? []),
|
||||
...Object.keys(sources),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (nbHits) {
|
||||
const currentNbHits = sourcesState.context.nbHits as number | undefined;
|
||||
setContext({
|
||||
nbHits: (currentNbHits ?? 0) + nbHits,
|
||||
});
|
||||
}
|
||||
|
||||
let insightsParams = {};
|
||||
|
||||
if (insightsActive) {
|
||||
insightsParams = {
|
||||
__autocomplete_indexName: result.index,
|
||||
__autocomplete_queryID: result.queryID,
|
||||
__autocomplete_algoliaCredentials: { appId, apiKey },
|
||||
};
|
||||
}
|
||||
|
||||
return Object.values<DocSearchHit[]>(sources).map((items, index) => ({
|
||||
sourceId: `hits_${result.index}_${index}`,
|
||||
onSelect({ item, event }): void {
|
||||
saveRecentSearch(item);
|
||||
if (!isModifierEvent(event)) {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
getItemUrl({ item }): string {
|
||||
return item.url;
|
||||
},
|
||||
getItems(): InternalDocSearchHit[] {
|
||||
return Object.values(groupBy(items, (item) => item.hierarchy.lvl1, maxResultsPerGroup))
|
||||
.map((groupedHits) =>
|
||||
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;
|
||||
|
||||
if (item.type !== 'lvl1' && potentialParent) {
|
||||
parent = potentialParent;
|
||||
}
|
||||
|
||||
return {
|
||||
...item,
|
||||
__docsearch_parent: parent,
|
||||
...insightsParams,
|
||||
};
|
||||
}),
|
||||
)
|
||||
.flat();
|
||||
},
|
||||
}));
|
||||
});
|
||||
} catch (error) {
|
||||
if ((error as Error).name === 'RetryError') {
|
||||
setStatus('error');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
import type { SearchParamsObject } from 'algoliasearch/lite';
|
||||
|
||||
import type { DocSearchIndex } from '../DocSearch';
|
||||
|
||||
export function normalizeDocSearchIndexes({
|
||||
indexName,
|
||||
indices = [],
|
||||
searchParameters,
|
||||
}: {
|
||||
indexName?: string;
|
||||
indices?: Array<DocSearchIndex | string>;
|
||||
searchParameters?: SearchParamsObject;
|
||||
}): DocSearchIndex[] {
|
||||
const indexes: DocSearchIndex[] = [];
|
||||
|
||||
if (indexName && indexName !== '') {
|
||||
indexes.push({
|
||||
name: indexName,
|
||||
searchParameters,
|
||||
});
|
||||
}
|
||||
|
||||
if (indices.length > 0) {
|
||||
indices.forEach((index) => {
|
||||
indexes.push(typeof index === 'string' ? { name: index } : index);
|
||||
});
|
||||
}
|
||||
|
||||
if (indexes.length < 1) {
|
||||
throw new Error('Must supply either `indexName` or `indices` for DocSearch to work');
|
||||
}
|
||||
|
||||
return indexes;
|
||||
}
|
||||
|
|
@ -25,6 +25,8 @@ export default defineConfig([
|
|||
{
|
||||
entry: {
|
||||
index: 'src/index.ts',
|
||||
DocSearchAI: 'src/DocSearchAI.tsx',
|
||||
DocSearchAskAiModal: 'src/DocSearchAskAiModal.tsx',
|
||||
DocSearchButton: 'src/DocSearchButton.tsx',
|
||||
DocSearchModal: 'src/DocSearchModal.tsx',
|
||||
useDocSearchKeyboardEvents: 'src/useDocSearchKeyboardEvents.ts',
|
||||
|
|
|
|||
|
|
@ -4,6 +4,10 @@ export default defineConfig({
|
|||
test: {
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['./scripts/setupTests.ts'],
|
||||
include: ['packages/**/src/**/*.test.ts', 'tests/**/*.test.ts'],
|
||||
}
|
||||
include: [
|
||||
'packages/**/src/**/*.test.ts',
|
||||
'packages/**/src/**/*.test.tsx',
|
||||
'tests/**/*.test.ts',
|
||||
],
|
||||
},
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue