diff --git a/packages/instantsearch.js/src/lib/utils/getStateFromSearchToolInput.ts b/packages/instantsearch.js/src/lib/utils/getStateFromSearchToolInput.ts new file mode 100644 index 0000000000..4ae0ecf29e --- /dev/null +++ b/packages/instantsearch.js/src/lib/utils/getStateFromSearchToolInput.ts @@ -0,0 +1,37 @@ +import type { IndexUiState, SearchToolInput } from '../../types'; + +export function generateIndexUiState( + input: SearchToolInput, + currentUiState: IndexUiState +): IndexUiState { + const indexUiState: IndexUiState = { ...currentUiState }; + + if (input.query) { + indexUiState.query = input.query; + } + + if (input.facet_filters) { + let uiStateToUpdate: 'refinementList' | 'menu' | null = null; + + if (indexUiState.refinementList) { + uiStateToUpdate = 'refinementList'; + } else if (indexUiState.menu) { + uiStateToUpdate = 'menu'; + } + + if (uiStateToUpdate) { + input.facet_filters.forEach((facetFilter) => { + facetFilter.forEach((filter) => { + const [facet, value] = filter.split(':'); + + if (indexUiState[uiStateToUpdate]?.[facet]) { + indexUiState[uiStateToUpdate]![facet] = []; + (indexUiState[uiStateToUpdate]![facet] as string[]).push(value); + } + }); + }); + } + } + + return indexUiState; +} diff --git a/packages/instantsearch.js/src/lib/utils/index.ts b/packages/instantsearch.js/src/lib/utils/index.ts index 406ed5619b..06cd7df51e 100644 --- a/packages/instantsearch.js/src/lib/utils/index.ts +++ b/packages/instantsearch.js/src/lib/utils/index.ts @@ -50,3 +50,4 @@ export * from './safelyRunOnBrowser'; export * from './serializer'; export * from './toArray'; export * from './uniq'; +export * from './getStateFromSearchToolInput'; diff --git a/packages/instantsearch.js/src/types/widget.ts b/packages/instantsearch.js/src/types/widget.ts index 5904e24002..c92226124e 100644 --- a/packages/instantsearch.js/src/types/widget.ts +++ b/packages/instantsearch.js/src/types/widget.ts @@ -377,3 +377,9 @@ export type SortBy = * Creates the URL for the given value. */ export type CreateURL = (value: TValue) => string; + +export type SearchToolInput = { + query: string; + number_of_results?: number; + facet_filters?: string[][]; +}; diff --git a/packages/instantsearch.js/src/widgets/chat/chat.tsx b/packages/instantsearch.js/src/widgets/chat/chat.tsx index deb678d4d3..c0818d670e 100644 --- a/packages/instantsearch.js/src/widgets/chat/chat.tsx +++ b/packages/instantsearch.js/src/widgets/chat/chat.tsx @@ -1,14 +1,7 @@ /** @jsx h */ -import { - ArrowRightIcon, - ChevronLeftIcon, - ChevronRightIcon, - createButtonComponent, - createChatComponent, -} from 'instantsearch-ui-components'; +import { createChatComponent } from 'instantsearch-ui-components'; import { Fragment, h, render } from 'preact'; -import { useMemo } from 'preact/hooks'; import TemplateComponent from '../../components/Template/Template'; import connectChat from '../../connectors/chat/connectChat'; @@ -19,7 +12,8 @@ import { getContainerNode, createDocumentationMessageGenerator, } from '../../lib/utils'; -import { carousel } from '../../templates'; + +import { createCarouselTool } from './search-index-tool'; import type { ChatRenderState, @@ -70,197 +64,6 @@ function getDefinedProperties(obj: T): Partial { ) as Partial; } -function createCarouselTool< - THit extends RecordWithObjectID = RecordWithObjectID ->( - showViewAll: boolean, - templates: ChatTemplates, - getSearchPageURL?: (nextUiState: IndexUiState) => string -): UserClientSideToolWithTemplate { - const Button = createButtonComponent({ - createElement: h, - }); - - function SearchLayoutComponent({ - message, - indexUiState, - setIndexUiState, - onClose, - }: ClientSideToolComponentProps) { - const input = message?.input as - | { - query: string; - number_of_results?: number; - } - | undefined; - - const output = message?.output as - | { - hits?: Array>; - nbHits?: number; - } - | undefined; - - const items = output?.hits || []; - - const MemoedHeaderComponent = useMemo(() => { - return ( - props: Omit< - ComponentProps, - | 'nbHits' - | 'query' - | 'hitsPerPage' - | 'setIndexUiState' - | 'indexUiState' - | 'getSearchPageURL' - | 'onClose' - > - ) => ( - - ); - }, [ - items.length, - input?.query, - output?.nbHits, - setIndexUiState, - indexUiState, - onClose, - ]); - - return carousel({ - showNavigation: false, - templates: { - header: MemoedHeaderComponent, - }, - })({ - items, - templates: { - item: ({ item }) => ( - - ), - }, - sendEvent: () => {}, - }); - } - - function HeaderComponent({ - canScrollLeft, - canScrollRight, - scrollLeft, - scrollRight, - nbHits, - query, - hitsPerPage, - setIndexUiState, - indexUiState, - onClose, - // eslint-disable-next-line no-shadow - getSearchPageURL, - }: { - canScrollLeft: boolean; - canScrollRight: boolean; - scrollLeft: () => void; - scrollRight: () => void; - nbHits?: number; - query?: string; - hitsPerPage?: number; - setIndexUiState: IndexWidget['setIndexUiState']; - indexUiState: IndexUiState; - onClose: () => void; - getSearchPageURL?: (nextUiState: IndexUiState) => string; - }) { - if ((hitsPerPage ?? 0) < 1) { - return null; - } - - return ( -
-
- {nbHits && ( -
- {hitsPerPage ?? 0} of {nbHits.toLocaleString()} result - {nbHits > 1 ? 's' : ''} -
- )} - {showViewAll && ( - - )} -
- - {(hitsPerPage ?? 0) > 2 && ( -
- - -
- )} -
- ); - } - - return { - templates: { layout: SearchLayoutComponent }, - }; -} - function createDefaultTools< THit extends RecordWithObjectID = RecordWithObjectID >( @@ -857,7 +660,7 @@ export type UserClientSideToolTemplates = Partial<{ layout: TemplateWithBindEvent; }>; -type UserClientSideToolWithTemplate = Omit< +export type UserClientSideToolWithTemplate = Omit< UserClientSideTool, 'layoutComponent' > & { diff --git a/packages/instantsearch.js/src/widgets/chat/search-index-tool.tsx b/packages/instantsearch.js/src/widgets/chat/search-index-tool.tsx new file mode 100644 index 0000000000..e2437ef360 --- /dev/null +++ b/packages/instantsearch.js/src/widgets/chat/search-index-tool.tsx @@ -0,0 +1,213 @@ +/** @jsx h */ + +import { + ArrowRightIcon, + ChevronLeftIcon, + ChevronRightIcon, + createButtonComponent, +} from 'instantsearch-ui-components'; +import { h } from 'preact'; +import { useMemo } from 'preact/hooks'; + +import TemplateComponent from '../../components/Template/Template'; +import { generateIndexUiState } from '../../lib/utils/getStateFromSearchToolInput'; +import { carousel } from '../../templates'; + +import type { IndexUiState, IndexWidget, SearchToolInput } from '../../types'; +import type { ChatTemplates, UserClientSideToolWithTemplate } from './chat'; +import type { + ClientSideToolComponentProps, + ComponentProps, + RecordWithObjectID, +} from 'instantsearch-ui-components'; + +export function createCarouselTool< + THit extends RecordWithObjectID = RecordWithObjectID +>( + showViewAll: boolean, + templates: ChatTemplates, + getSearchPageURL?: (nextUiState: IndexUiState) => string +): UserClientSideToolWithTemplate { + const Button = createButtonComponent({ + createElement: h, + }); + + function SearchLayoutComponent({ + message, + indexUiState, + setIndexUiState, + onClose, + }: ClientSideToolComponentProps) { + const input = message?.input as SearchToolInput | undefined; + + const output = message?.output as + | { + hits?: Array>; + nbHits?: number; + } + | undefined; + + const items = output?.hits || []; + + const MemoedHeaderComponent = useMemo(() => { + return ( + props: Omit< + // @ts-expect-error + ComponentProps, + | 'nbHits' + | 'query' + | 'hitsPerPage' + | 'setIndexUiState' + | 'indexUiState' + | 'getSearchPageURL' + | 'onClose' + > + ) => ( + // @ts-expect-error + + ); + }, [ + items.length, + input, + output?.nbHits, + setIndexUiState, + indexUiState, + onClose, + ]); + + return carousel({ + showNavigation: false, + templates: { + header: MemoedHeaderComponent, + }, + })({ + items, + templates: { + item: ({ item }) => ( + + ), + }, + sendEvent: () => {}, + }); + } + + function HeaderComponent({ + canScrollLeft, + canScrollRight, + scrollLeft, + scrollRight, + nbHits, + input, + hitsPerPage, + setIndexUiState, + indexUiState, + onClose, + // eslint-disable-next-line no-shadow + getSearchPageURL, + }: { + canScrollLeft: boolean; + canScrollRight: boolean; + scrollLeft: () => void; + scrollRight: () => void; + nbHits?: number; + input?: SearchToolInput; + hitsPerPage?: number; + setIndexUiState: IndexWidget['setIndexUiState']; + indexUiState: IndexUiState; + onClose: () => void; + getSearchPageURL?: (nextUiState: IndexUiState) => string; + }) { + if ((hitsPerPage ?? 0) < 1) { + return null; + } + + return ( +
+
+ {nbHits && ( +
+ {hitsPerPage ?? 0} of {nbHits.toLocaleString()} result + {nbHits > 1 ? 's' : ''} +
+ )} + {showViewAll && ( + + )} +
+ + {(hitsPerPage ?? 0) > 2 && ( +
+ + +
+ )} +
+ ); + } + + return { + templates: { layout: SearchLayoutComponent }, + }; +} diff --git a/packages/react-instantsearch/src/widgets/chat/tools/SearchIndexTool.tsx b/packages/react-instantsearch/src/widgets/chat/tools/SearchIndexTool.tsx index 865b52832a..270014523f 100644 --- a/packages/react-instantsearch/src/widgets/chat/tools/SearchIndexTool.tsx +++ b/packages/react-instantsearch/src/widgets/chat/tools/SearchIndexTool.tsx @@ -4,6 +4,7 @@ import { ArrowRightIcon, createButtonComponent, } from 'instantsearch-ui-components'; +import { generateIndexUiState } from 'instantsearch.js/es/lib/utils'; import React, { createElement } from 'react'; import { Carousel } from '../../../components'; @@ -20,6 +21,12 @@ import type { ComponentProps } from 'react'; type ItemComponent = RecommendComponentProps['itemComponent']; +type SearchToolInput = { + query: string; + number_of_results?: number; + facet_filters?: string[][]; +}; + function createCarouselTool( showViewAll: boolean, itemComponent?: ItemComponent, @@ -35,12 +42,7 @@ function createCarouselTool( setIndexUiState, onClose, }: ClientSideToolComponentProps) { - const input = message?.input as - | { - query: string; - number_of_results?: number; - } - | undefined; + const input = message?.input as SearchToolInput | undefined; const output = message?.output as | { @@ -66,7 +68,7 @@ function createCarouselTool( ) => ( ( ); }, [ items.length, - input?.query, + input, output?.nbHits, setIndexUiState, onClose, @@ -101,7 +103,7 @@ function createCarouselTool( scrollLeft, scrollRight, nbHits, - query, + input, hitsPerPage, setIndexUiState, indexUiState, @@ -114,7 +116,7 @@ function createCarouselTool( scrollLeft: () => void; scrollRight: () => void; nbHits?: number; - query?: string; + input?: SearchToolInput; hitsPerPage?: number; setIndexUiState: IndexWidget['setIndexUiState']; indexUiState: IndexUiState; @@ -139,9 +141,12 @@ function createCarouselTool( variant="ghost" size="sm" onClick={() => { - if (!query) return; + if (!input?.query) return; - const nextUiState = { ...indexUiState, query }; + const nextUiState = { + ...indexUiState, + ...generateIndexUiState(input, indexUiState), + }; // If no main search page URL or we are on the search page, just update the state if ( diff --git a/tests/common/widgets/chat/options.tsx b/tests/common/widgets/chat/options.tsx index 8442a29656..0f1f5ea197 100644 --- a/tests/common/widgets/chat/options.tsx +++ b/tests/common/widgets/chat/options.tsx @@ -3,7 +3,9 @@ import { createSearchClient } from '@instantsearch/mocks'; import { wait } from '@instantsearch/testutils'; import userEvent from '@testing-library/user-event'; import { Chat, SearchIndexToolType } from 'instantsearch.js/es/lib/chat'; +import { createCarouselTool as jsCreateCarouselTool } from 'instantsearch.js/src/widgets/chat/search-index-tool'; import React from 'react'; +import { createCarouselTool as reactCreateCarouselTool } from 'react-instantsearch/src/widgets/chat/tools/SearchIndexTool'; import { createDefaultWidgetParams, openChat } from './utils'; @@ -425,6 +427,320 @@ export function createOptionsTests( 'The message said hello!' ); }); + + describe('search tool index UI state', () => { + test('updates the index UI if refinementList is present', async () => { + const searchClient = createSearchClient(); + + const chat = new Chat({ + messages: [ + { + id: '1', + role: 'assistant', + parts: [ + { + type: `tool-${SearchIndexToolType}`, + toolCallId: '1', + input: { + query: 'iphone', + number_of_results: 1, + facet_filters: [['brand:Apple', 'category:Smartphones']], + }, + state: 'output-available', + output: { hits: [{ objectID: '123' }] }, + }, + ], + }, + ], + id: 'chat-id', + }); + + const mockSetIndexUiState = jest.fn(); + + const currentUiState = { + refinementList: { + brand: [], + category: [], + }, + menu: { + brand: [], + category: [], + }, + }; + + const JsTool = jsCreateCarouselTool(true, { + item: '
Item
', + }); + const ReactTool = reactCreateCarouselTool(true); + + await setup({ + instantSearchOptions: { + indexName: 'indexName', + searchClient, + }, + widgetParams: { + javascript: { + ...createDefaultWidgetParams(chat), + tools: { + [SearchIndexToolType]: { + templates: { + layout: (props) => + JsTool.templates.layout + ? // @ts-expect-error + JsTool.templates.layout({ + ...props, + indexUiState: currentUiState, + setIndexUiState: mockSetIndexUiState, + }) + : null, + }, + }, + }, + }, + react: { + ...createDefaultWidgetParams(chat), + tools: { + [SearchIndexToolType]: { + // @ts-expect-error + layoutComponent: (props) => + ReactTool.layoutComponent ? ( + + ) : null, + }, + }, + }, + vue: {}, + }, + }); + + await openChat(act); + + userEvent.click( + document.querySelector( + '.ais-ChatToolSearchIndexCarouselHeaderViewAll' + )! + ); + + await act(async () => { + await wait(0); + }); + + expect(mockSetIndexUiState).toHaveBeenCalledWith({ + query: 'iphone', + refinementList: { + brand: ['Apple'], + category: ['Smartphones'], + }, + menu: { + brand: [], + category: [], + }, + }); + }); + + test('updates the index UI if menu is present', async () => { + const searchClient = createSearchClient(); + + const chat = new Chat({ + messages: [ + { + id: '1', + role: 'assistant', + parts: [ + { + type: `tool-${SearchIndexToolType}`, + toolCallId: '1', + input: { + query: 'iphone', + number_of_results: 1, + facet_filters: [['brand:Apple', 'category:Smartphones']], + }, + state: 'output-available', + output: { hits: [{ objectID: '123' }] }, + }, + ], + }, + ], + id: 'chat-id', + }); + + const mockSetIndexUiState = jest.fn(); + + const currentUiState = { + menu: { + brand: [], + category: [], + }, + }; + + const JsTool = jsCreateCarouselTool(true, { + item: '
Item
', + }); + const ReactTool = reactCreateCarouselTool(true); + + await setup({ + instantSearchOptions: { + indexName: 'indexName', + searchClient, + }, + widgetParams: { + javascript: { + ...createDefaultWidgetParams(chat), + tools: { + [SearchIndexToolType]: { + templates: { + layout: (props) => + JsTool.templates.layout + ? // @ts-expect-error + JsTool.templates.layout({ + ...props, + indexUiState: currentUiState, + setIndexUiState: mockSetIndexUiState, + }) + : null, + }, + }, + }, + }, + react: { + ...createDefaultWidgetParams(chat), + tools: { + [SearchIndexToolType]: { + // @ts-expect-error + layoutComponent: (props) => + ReactTool.layoutComponent ? ( + + ) : null, + }, + }, + }, + vue: {}, + }, + }); + + await openChat(act); + + userEvent.click( + document.querySelector( + '.ais-ChatToolSearchIndexCarouselHeaderViewAll' + )! + ); + + await act(async () => { + await wait(0); + }); + + expect(mockSetIndexUiState).toHaveBeenCalledWith({ + query: 'iphone', + menu: { + brand: ['Apple'], + category: ['Smartphones'], + }, + }); + }); + + test('does not update the index UI if no refinements are possible', async () => { + const searchClient = createSearchClient(); + + const chat = new Chat({ + messages: [ + { + id: '1', + role: 'assistant', + parts: [ + { + type: `tool-${SearchIndexToolType}`, + toolCallId: '1', + input: { + query: 'iphone', + number_of_results: 1, + facet_filters: [['brand:Apple', 'category:Smartphones']], + }, + state: 'output-available', + output: { hits: [{ objectID: '123' }] }, + }, + ], + }, + ], + id: 'chat-id', + }); + + const mockSetIndexUiState = jest.fn(); + + const currentUiState = {}; + + const JsTool = jsCreateCarouselTool(true, { + item: '
Item
', + }); + const ReactTool = reactCreateCarouselTool(true); + + await setup({ + instantSearchOptions: { + indexName: 'indexName', + searchClient, + }, + widgetParams: { + javascript: { + ...createDefaultWidgetParams(chat), + tools: { + [SearchIndexToolType]: { + templates: { + layout: (props) => + JsTool.templates.layout + ? // @ts-expect-error + JsTool.templates.layout({ + ...props, + indexUiState: currentUiState, + setIndexUiState: mockSetIndexUiState, + }) + : null, + }, + }, + }, + }, + react: { + ...createDefaultWidgetParams(chat), + tools: { + [SearchIndexToolType]: { + // @ts-expect-error + layoutComponent: (props) => + ReactTool.layoutComponent ? ( + + ) : null, + }, + }, + }, + vue: {}, + }, + }); + + await openChat(act); + + userEvent.click( + document.querySelector( + '.ais-ChatToolSearchIndexCarouselHeaderViewAll' + )! + ); + + await act(async () => { + await wait(0); + }); + + expect(mockSetIndexUiState).toHaveBeenCalledWith({ query: 'iphone' }); + }); + }); }); }); }