diff --git a/bundlesize.config.json b/bundlesize.config.json index 1da135b564f..de9e6bd5eb9 100644 --- a/bundlesize.config.json +++ b/bundlesize.config.json @@ -10,11 +10,11 @@ }, { "path": "./packages/instantsearch.js/dist/instantsearch.production.min.js", - "maxSize": "84.75 kB" + "maxSize": "85 kB" }, { "path": "./packages/instantsearch.js/dist/instantsearch.development.js", - "maxSize": "185 kB" + "maxSize": "183.75 kB" }, { "path": "packages/react-instantsearch-core/dist/umd/ReactInstantSearchCore.min.js", diff --git a/packages/algolia-experiences/src/render.tsx b/packages/algolia-experiences/src/render.tsx index 166f10e5054..106c3bff73e 100644 --- a/packages/algolia-experiences/src/render.tsx +++ b/packages/algolia-experiences/src/render.tsx @@ -1,7 +1,7 @@ /** @jsx h */ import { getPropertyByPath } from 'instantsearch.js/es/lib/utils'; import { carousel } from 'instantsearch.js/es/templates'; -import { index, panel } from 'instantsearch.js/es/widgets'; +import { index, panel } from 'instantsearch.js/es/widgets/index.umd'; import { h, Fragment } from 'preact'; import { banner } from './banner'; diff --git a/packages/algolia-experiences/src/widgets.ts b/packages/algolia-experiences/src/widgets.ts index 5ea95f293d5..3dd62a4f055 100644 --- a/packages/algolia-experiences/src/widgets.ts +++ b/packages/algolia-experiences/src/widgets.ts @@ -22,7 +22,7 @@ import { stats, toggleRefinement, trendingItems, -} from 'instantsearch.js/es/widgets'; +} from 'instantsearch.js/es/widgets/index.umd'; export const widgets = { 'ais.breadcrumb': breadcrumb, diff --git a/packages/instantsearch-ui-components/src/components/chat/Chat.tsx b/packages/instantsearch-ui-components/src/components/chat/Chat.tsx index b3721c48257..9567f031f6d 100644 --- a/packages/instantsearch-ui-components/src/components/chat/Chat.tsx +++ b/packages/instantsearch-ui-components/src/components/chat/Chat.tsx @@ -98,7 +98,7 @@ export function createChatComponent({ createElement, Fragment }: Renderer) { {...toggleButtonProps} onClick={() => { toggleButtonProps.onClick?.(); - promptRef.current?.focus(); + promptRef.current?.focus?.(); }} /> diff --git a/packages/instantsearch.js/package.json b/packages/instantsearch.js/package.json index bcd074a0fba..728a659b10d 100644 --- a/packages/instantsearch.js/package.json +++ b/packages/instantsearch.js/package.json @@ -31,6 +31,7 @@ "@types/google.maps": "^3.55.12", "@types/hogan.js": "^3.0.0", "@types/qs": "^6.5.3", + "ai": "^5.0.18", "algoliasearch-helper": "3.26.0", "hogan.js": "^3.0.2", "htm": "^3.0.0", diff --git a/packages/instantsearch.js/src/__tests__/common-connectors.test.tsx b/packages/instantsearch.js/src/__tests__/common-connectors.test.tsx index 1b0f8c6e29f..46b77aa4ae5 100644 --- a/packages/instantsearch.js/src/__tests__/common-connectors.test.tsx +++ b/packages/instantsearch.js/src/__tests__/common-connectors.test.tsx @@ -19,6 +19,7 @@ import { connectFrequentlyBoughtTogether, connectTrendingItems, connectLookingSimilar, + connectChat, } from '../connectors'; import instantsearch from '../index.es'; import { refinementList } from '../widgets'; @@ -530,6 +531,51 @@ const testSetups: TestSetupsMap = { search.start(); }, + createChatConnectorTests({ instantSearchOptions, widgetParams }) { + const customChat = connectChat<{ + container: HTMLElement; + }>((renderOptions) => { + const { input, setInput, open, setOpen } = renderOptions; + renderOptions.widgetParams.container.innerHTML = ` +
+ + +
+ + `; + + renderOptions.widgetParams.container + .querySelector('[data-testid="Chat-toggleButton"]')! + .addEventListener('click', () => { + setOpen(!open); + }); + + renderOptions.widgetParams.container + .querySelector('[data-testid="Chat-updateInput"]')! + .addEventListener('click', () => { + setInput('hello world'); + }); + }); + + instantsearch(instantSearchOptions) + .addWidgets([ + customChat({ + container: document.body.appendChild(document.createElement('div')), + ...widgetParams, + }), + ]) + .on('error', () => { + /* + * prevent rethrowing InstantSearch errors, so tests can be asserted. + * IRL this isn't needed, as the error doesn't stop execution. + */ + }) + .start(); + }, }; function addWidgetToggleUi(search: InstantSearch, widget: Widget) { @@ -561,6 +607,7 @@ const testOptions: TestOptionsMap = { createFrequentlyBoughtTogetherConnectorTests: undefined, createTrendingItemsConnectorTests: undefined, createLookingSimilarConnectorTests: undefined, + createChatConnectorTests: undefined, }; describe('Common connector tests (InstantSearch.js)', () => { diff --git a/packages/instantsearch.js/src/__tests__/common-widgets.test.tsx b/packages/instantsearch.js/src/__tests__/common-widgets.test.tsx index 983279477c4..4f4440f7bd5 100644 --- a/packages/instantsearch.js/src/__tests__/common-widgets.test.tsx +++ b/packages/instantsearch.js/src/__tests__/common-widgets.test.tsx @@ -32,6 +32,7 @@ import { poweredBy, menuSelect, dynamicWidgets, + chat, } from '../widgets'; import type { TestOptionsMap, TestSetupsMap } from '@instantsearch/tests'; @@ -617,6 +618,22 @@ const testSetups: TestSetupsMap = { ]) .start(); }, + createChatWidgetTests({ instantSearchOptions, widgetParams }) { + instantsearch(instantSearchOptions) + .addWidgets([ + chat({ + container: document.body.appendChild(document.createElement('div')), + ...widgetParams, + }), + ]) + .on('error', () => { + /* + * prevent rethrowing InstantSearch errors, so tests can be asserted. + * IRL this isn't needed, as the error doesn't stop execution. + */ + }) + .start(); + }, }; const testOptions: TestOptionsMap = { @@ -649,6 +666,7 @@ const testOptions: TestOptionsMap = { createPoweredByWidgetTests: undefined, createMenuSelectWidgetTests: undefined, createDynamicWidgetsWidgetTests: undefined, + createChatWidgetTests: undefined, }; describe('Common widget tests (InstantSearch.js)', () => { diff --git a/packages/instantsearch.js/src/connectors/__tests__/index.test.ts b/packages/instantsearch.js/src/connectors/__tests__/index.test.ts new file mode 100644 index 00000000000..fe5664286e4 --- /dev/null +++ b/packages/instantsearch.js/src/connectors/__tests__/index.test.ts @@ -0,0 +1,10 @@ +import * as connectors from '..'; +import * as connectorsUmd from '../index.umd'; + +describe('connectors', () => { + describe('umd', () => { + test('has the same number of exports as the main entrypoint', () => { + expect(Object.keys(connectorsUmd)).toEqual(Object.keys(connectors)); + }); + }); +}); diff --git a/packages/instantsearch.js/src/connectors/chat/__tests__/connectChat-test.ts b/packages/instantsearch.js/src/connectors/chat/__tests__/connectChat-test.ts new file mode 100644 index 00000000000..004aa232480 --- /dev/null +++ b/packages/instantsearch.js/src/connectors/chat/__tests__/connectChat-test.ts @@ -0,0 +1,87 @@ +import { createSearchClient } from '@instantsearch/mocks'; +import algoliasearchHelper from 'algoliasearch-helper'; + +import { + createInitOptions, + createRenderOptions, +} from '../../../../test/createWidget'; +import connectChat from '../connectChat'; + +describe('connectChat', () => { + it('throws without render function', () => { + expect(() => { + // @ts-expect-error + connectChat()({}); + }).toThrowErrorMatchingInlineSnapshot(` + "The render function is not valid (received type Undefined). + + See documentation: https://www.algolia.com/doc/api-reference/widgets/chat/js/#connector" + `); + }); + + it('is a widget', () => { + const render = jest.fn(); + const unmount = jest.fn(); + + const customChat = connectChat(render, unmount); + const widget = customChat({}); + + expect(widget).toEqual( + expect.objectContaining({ + $$type: 'ais.chat', + init: expect.any(Function), + render: expect.any(Function), + dispose: expect.any(Function), + }) + ); + }); + + it('Renders during init and render', () => { + const renderFn = jest.fn(); + const makeWidget = connectChat(renderFn); + const widget = makeWidget({ agentId: 'agentId' }); + + // test if widget is not rendered yet at this point + expect(renderFn).toHaveBeenCalledTimes(0); + + const helper = algoliasearchHelper(createSearchClient(), '', {}); + helper.search = jest.fn(); + + widget.init(createInitOptions({ helper, state: helper.state })); + + expect(renderFn).toHaveBeenCalledTimes(1); + expect(renderFn).toHaveBeenLastCalledWith( + expect.objectContaining({ widgetParams: { agentId: 'agentId' } }), + true + ); + + const renderOptions = createRenderOptions({ helper }); + widget.render(renderOptions); + + expect(renderFn).toHaveBeenCalledTimes(2); + expect(renderFn).toHaveBeenLastCalledWith( + expect.objectContaining({ widgetParams: { agentId: 'agentId' } }), + false + ); + }); + + describe('dispose', () => { + it('calls the unmount function', () => { + const unmountFn = jest.fn(); + const makeWidget = connectChat(() => {}, unmountFn); + const widget = makeWidget({ agentId: 'agentId' }); + + expect(unmountFn).toHaveBeenCalledTimes(0); + + widget.dispose(); + expect(unmountFn).toHaveBeenCalledTimes(1); + }); + + it('does not throw without the unmount function', () => { + const makeWidget = connectChat(() => {}); + const widget = makeWidget({ agentId: 'agentId' }); + + expect(() => widget.dispose()).not.toThrow(); + }); + }); +}); diff --git a/packages/instantsearch.js/src/connectors/chat/connectChat.ts b/packages/instantsearch.js/src/connectors/chat/connectChat.ts new file mode 100644 index 00000000000..7ca585cf75d --- /dev/null +++ b/packages/instantsearch.js/src/connectors/chat/connectChat.ts @@ -0,0 +1,272 @@ +import { DefaultChatTransport } from 'ai'; + +import { Chat } from '../../lib/chat'; +import { + checkRendering, + createDocumentationMessageGenerator, + createSendEventForHits, + getAppIdAndApiKey, + noop, +} from '../../lib/utils'; + +import type { + AbstractChat, + ChatInit as ChatInitAi, + UIMessage, +} from '../../lib/chat'; +import type { SendEventForHits } from '../../lib/utils'; +import type { + Connector, + Renderer, + Unmounter, + UnknownWidgetParams, + InstantSearch, + IndexUiState, + IndexWidget, +} from '../../types'; + +const withUsage = createDocumentationMessageGenerator({ + name: 'chat', + connector: true, +}); + +export type ChatRenderState = { + indexUiState: IndexUiState; + input: string; + open: boolean; + /** + * Sends an event to the Insights middleware. + */ + sendEvent: SendEventForHits; + setIndexUiState: IndexWidget['setIndexUiState']; + setInput: (input: string) => void; + setOpen: (open: boolean) => void; + /** + * Updates the `messages` state locally. This is useful when you want to + * edit the messages on the client, and then trigger the `reload` method + * manually to regenerate the AI response. + */ + setMessages: ( + messages: TUiMessage[] | ((m: TUiMessage[]) => TUiMessage[]) + ) => void; +} & Pick< + AbstractChat, + | 'addToolResult' + | 'clearError' + | 'error' + | 'id' + | 'messages' + | 'regenerate' + | 'resumeStream' + | 'sendMessage' + | 'status' + | 'stop' +>; + +export type ChatInitWithoutTransport = Omit< + ChatInitAi, + 'transport' +>; + +export type ChatTransport = { + agentId?: string; + transport?: ConstructorParameters[0]; +}; + +export type ChatInit = + ChatInitWithoutTransport & ChatTransport; + +export type ChatConnectorParams = ( + | { chat: Chat } + | ChatInit +) & { + /** + * Whether to resume an ongoing chat generation stream. + */ + resume?: boolean; +}; + +export type ChatWidgetDescription = { + $$type: 'ais.chat'; + renderState: ChatRenderState; + indexRenderState: Record; +}; + +export type ChatConnector = Connector< + ChatWidgetDescription, + ChatConnectorParams +>; + +export default (function connectChat( + renderFn: Renderer, + unmountFn: Unmounter = noop +) { + checkRendering(renderFn, withUsage()); + + return ( + widgetParams: TWidgetParams & ChatConnectorParams + ) => { + const { resume = false, ...options } = widgetParams || {}; + + let _chatInstance: Chat; + let input = ''; + let open = false; + let sendEvent: SendEventForHits; + let setInput: ChatRenderState['setInput']; + let setOpen: ChatRenderState['setOpen']; + + const setMessages = ( + messagesParam: TUiMessage[] | ((m: TUiMessage[]) => TUiMessage[]) + ) => { + if (typeof messagesParam === 'function') { + messagesParam = messagesParam(_chatInstance.messages); + } + _chatInstance.messages = messagesParam; + }; + + const makeChatInstance = (instantSearchInstance: InstantSearch) => { + let transport; + const [appId, apiKey] = getAppIdAndApiKey(instantSearchInstance.client); + if ('transport' in options && options.transport) { + transport = new DefaultChatTransport(options.transport); + } + if ('agentId' in options && options.agentId) { + const { agentId } = options; + if (!appId || !apiKey) { + throw new Error( + withUsage( + 'Could not extract Algolia credentials from the search client.' + ) + ); + } + transport = new DefaultChatTransport({ + api: `https://${appId}.algolia.net/agent-studio/1/agents/${agentId}/completions?compatibilityMode=ai-sdk-5`, + headers: { + 'x-algolia-application-id': appId, + 'x-algolia-api-Key': apiKey, + }, + }); + } + if (!transport) { + throw new Error( + withUsage('You need to provide either an `agentId` or a `transport`.') + ); + } + + const optionsWithTransport = + 'chat' in options + ? options + : { + ...options, + transport, + }; + + return 'chat' in optionsWithTransport + ? optionsWithTransport.chat + : new Chat(optionsWithTransport); + }; + + return { + $$type: 'ais.chat', + + init(initOptions) { + const { instantSearchInstance } = initOptions; + + _chatInstance = makeChatInstance(instantSearchInstance); + + const render = () => { + renderFn( + { + ...this.getWidgetRenderState(initOptions), + instantSearchInstance: initOptions.instantSearchInstance, + }, + false + ); + }; + + setOpen = (o) => { + open = o; + render(); + }; + + setInput = (i) => { + input = i; + render(); + }; + + _chatInstance['~registerErrorCallback'](render); + _chatInstance['~registerMessagesCallback'](render); + _chatInstance['~registerStatusCallback'](render); + + if (resume) { + _chatInstance.resumeStream(); + } + + renderFn( + { + ...this.getWidgetRenderState(initOptions), + instantSearchInstance, + }, + true + ); + }, + + render(renderOptions) { + renderFn( + { + ...this.getWidgetRenderState(renderOptions), + instantSearchInstance: renderOptions.instantSearchInstance, + }, + false + ); + }, + + getRenderState(renderState) { + return renderState; + }, + + getWidgetRenderState(renderState) { + const { instantSearchInstance, parent } = renderState; + if (!_chatInstance) { + this.init!({ ...renderState, uiState: {}, results: undefined }); + } + + if (!sendEvent) { + sendEvent = createSendEventForHits({ + instantSearchInstance: renderState.instantSearchInstance, + helper: renderState.helper, + widgetType: this.$$type, + }); + } + + return { + indexUiState: instantSearchInstance.getUiState()[parent.getIndexId()], + input, + open, + sendEvent, + setIndexUiState: parent.setIndexUiState.bind(parent), + setInput, + setOpen, + setMessages, + widgetParams, + + // Chat instance render state + addToolResult: _chatInstance.addToolResult, + clearError: _chatInstance.clearError, + error: _chatInstance.error, + id: _chatInstance.id, + messages: _chatInstance.messages, + regenerate: _chatInstance.regenerate, + resumeStream: _chatInstance.resumeStream, + sendMessage: _chatInstance.sendMessage, + status: _chatInstance.status, + stop: _chatInstance.stop, + }; + }, + + dispose() { + unmountFn(); + }, + }; + }; +} satisfies ChatConnector); diff --git a/packages/instantsearch.js/src/connectors/index.ts b/packages/instantsearch.js/src/connectors/index.ts index f14dcde625e..4d4757316e5 100644 --- a/packages/instantsearch.js/src/connectors/index.ts +++ b/packages/instantsearch.js/src/connectors/index.ts @@ -54,3 +54,4 @@ export { default as connectVoiceSearch } from './voice-search/connectVoiceSearch export { default as connectRelevantSort } from './relevant-sort/connectRelevantSort'; export { default as connectFrequentlyBoughtTogether } from './frequently-bought-together/connectFrequentlyBoughtTogether'; export { default as connectLookingSimilar } from './looking-similar/connectLookingSimilar'; +export { default as connectChat } from './chat/connectChat'; diff --git a/packages/instantsearch.js/src/connectors/index.umd.ts b/packages/instantsearch.js/src/connectors/index.umd.ts new file mode 100644 index 00000000000..9b6af43e830 --- /dev/null +++ b/packages/instantsearch.js/src/connectors/index.umd.ts @@ -0,0 +1,65 @@ +import { deprecate } from '../lib/utils'; + +import connectAnswers from './answers/connectAnswers'; +import connectConfigureRelatedItems from './configure-related-items/connectConfigureRelatedItems'; +import connectDynamicWidgets from './dynamic-widgets/connectDynamicWidgets'; + +/** @deprecated answers is no longer supported */ +export const EXPERIMENTAL_connectAnswers = deprecate( + connectAnswers, + 'answers is no longer supported' +); + +/** @deprecated use connectRelatedItems instead */ +export const EXPERIMENTAL_connectConfigureRelatedItems = deprecate( + connectConfigureRelatedItems, + 'EXPERIMENTAL_connectConfigureRelatedItems is deprecated and will be removed in a next minor version of InstantSearch. Please use connectRelatedItems instead.' +); + +/** @deprecated use connectDynamicWidgets */ +export const EXPERIMENTAL_connectDynamicWidgets = deprecate( + connectDynamicWidgets, + 'use connectDynamicWidgets' +); + +export { connectDynamicWidgets }; + +export { default as connectClearRefinements } from './clear-refinements/connectClearRefinements'; +export { default as connectCurrentRefinements } from './current-refinements/connectCurrentRefinements'; +export { default as connectHierarchicalMenu } from './hierarchical-menu/connectHierarchicalMenu'; +export { default as connectHits } from './hits/connectHits'; +export { default as connectHitsWithInsights } from './hits/connectHitsWithInsights'; +export { default as connectHitsPerPage } from './hits-per-page/connectHitsPerPage'; +export { default as connectInfiniteHits } from './infinite-hits/connectInfiniteHits'; +export { default as connectInfiniteHitsWithInsights } from './infinite-hits/connectInfiniteHitsWithInsights'; +export { default as connectMenu } from './menu/connectMenu'; +export { default as connectNumericMenu } from './numeric-menu/connectNumericMenu'; +export { default as connectPagination } from './pagination/connectPagination'; +export { default as connectRange } from './range/connectRange'; +export { default as connectRefinementList } from './refinement-list/connectRefinementList'; +export { default as connectRelatedProducts } from './related-products/connectRelatedProducts'; +export { default as connectSearchBox } from './search-box/connectSearchBox'; +export { default as connectSortBy } from './sort-by/connectSortBy'; +export { default as connectRatingMenu } from './rating-menu/connectRatingMenu'; +export { default as connectStats } from './stats/connectStats'; +export { default as connectToggleRefinement } from './toggle-refinement/connectToggleRefinement'; +export { default as connectTrendingItems } from './trending-items/connectTrendingItems'; +export { default as connectBreadcrumb } from './breadcrumb/connectBreadcrumb'; +export { default as connectGeoSearch } from './geo-search/connectGeoSearch'; +export { default as connectPoweredBy } from './powered-by/connectPoweredBy'; +export { default as connectConfigure } from './configure/connectConfigure'; +export { default as connectAutocomplete } from './autocomplete/connectAutocomplete'; +export { default as connectQueryRules } from './query-rules/connectQueryRules'; +export { default as connectVoiceSearch } from './voice-search/connectVoiceSearch'; +export { default as connectRelevantSort } from './relevant-sort/connectRelevantSort'; +export { default as connectFrequentlyBoughtTogether } from './frequently-bought-together/connectFrequentlyBoughtTogether'; +export { default as connectLookingSimilar } from './looking-similar/connectLookingSimilar'; + +export const connectChat = () => { + throw new Error( + `"connectChat" is not available from the UMD build. + +Please use InstantSearch.js with a packaging system: +https://www.algolia.com/doc/guides/building-search-ui/installation/js/#with-a-packaging-system` + ); +}; diff --git a/packages/instantsearch.js/src/index.ts b/packages/instantsearch.js/src/index.ts index b99879f2e10..c47a0dc424a 100644 --- a/packages/instantsearch.js/src/index.ts +++ b/packages/instantsearch.js/src/index.ts @@ -1,4 +1,4 @@ -import * as connectors from './connectors/index'; +import * as connectors from './connectors/index.umd'; import * as helpers from './helpers/index'; import { createInfiniteHitsSessionStorageCache } from './lib/infiniteHitsCache/index'; import InstantSearch from './lib/InstantSearch'; @@ -7,7 +7,7 @@ import * as stateMappings from './lib/stateMappings/index'; import version from './lib/version'; import * as middlewares from './middlewares/index'; import * as templates from './templates/index'; -import * as widgets from './widgets/index'; +import * as widgets from './widgets/index.umd'; import type { InstantSearchOptions } from './lib/InstantSearch'; import type { Expand, UiState } from './types'; diff --git a/packages/instantsearch.js/src/types/render-state.ts b/packages/instantsearch.js/src/types/render-state.ts index 4064993deec..a8a00796088 100644 --- a/packages/instantsearch.js/src/types/render-state.ts +++ b/packages/instantsearch.js/src/types/render-state.ts @@ -1,6 +1,7 @@ import type { AnswersWidgetDescription } from '../connectors/answers/connectAnswers'; import type { AutocompleteWidgetDescription } from '../connectors/autocomplete/connectAutocomplete'; import type { BreadcrumbWidgetDescription } from '../connectors/breadcrumb/connectBreadcrumb'; +import type { ChatWidgetDescription } from '../connectors/chat/connectChat'; import type { ClearRefinementsWidgetDescription } from '../connectors/clear-refinements/connectClearRefinements'; import type { ConfigureWidgetDescription } from '../connectors/configure/connectConfigure'; import type { CurrentRefinementsWidgetDescription } from '../connectors/current-refinements/connectCurrentRefinements'; @@ -29,6 +30,7 @@ import type { PlacesWidgetDescription } from '../widgets/places/places'; type ConnectorRenderStates = AnswersWidgetDescription['indexRenderState'] & AutocompleteWidgetDescription['indexRenderState'] & BreadcrumbWidgetDescription['indexRenderState'] & + ChatWidgetDescription['indexRenderState'] & ClearRefinementsWidgetDescription['indexRenderState'] & ConfigureWidgetDescription['indexRenderState'] & CurrentRefinementsWidgetDescription['indexRenderState'] & diff --git a/packages/instantsearch.js/src/types/widget.ts b/packages/instantsearch.js/src/types/widget.ts index 0714853dec8..6cf02c2f3e1 100644 --- a/packages/instantsearch.js/src/types/widget.ts +++ b/packages/instantsearch.js/src/types/widget.ts @@ -62,6 +62,7 @@ export type BuiltinTypes = | 'ais.autocomplete' | 'ais.breadcrumb' | 'ais.clearRefinements' + | 'ais.chat' | 'ais.configure' | 'ais.configureRelatedItems' | 'ais.currentRefinements' @@ -100,6 +101,7 @@ export type BuiltinWidgetTypes = | 'ais.answers' | 'ais.autocomplete' | 'ais.breadcrumb' + | 'ais.chat' | 'ais.clearRefinements' | 'ais.configure' | 'ais.configureRelatedItems' diff --git a/packages/instantsearch.js/src/widgets/__tests__/index.test.ts b/packages/instantsearch.js/src/widgets/__tests__/index.test.ts index 9b1375e01b7..adde89fbb24 100644 --- a/packages/instantsearch.js/src/widgets/__tests__/index.test.ts +++ b/packages/instantsearch.js/src/widgets/__tests__/index.test.ts @@ -3,6 +3,7 @@ */ /* global google */ import * as widgets from '..'; +import * as widgetsUmd from '../index.umd'; import type { UnknownWidgetFactory, Widget } from '../../types'; import type { IndexWidget } from '../index/index'; @@ -218,4 +219,10 @@ describe('widgets', () => { }); }); }); + + describe('umd', () => { + test('has the same number of exports as the main entrypoint', () => { + expect(Object.keys(widgetsUmd)).toEqual(Object.keys(widgets)); + }); + }); }); diff --git a/packages/instantsearch.js/src/widgets/chat/__tests__/chat.test.tsx b/packages/instantsearch.js/src/widgets/chat/__tests__/chat.test.tsx new file mode 100644 index 00000000000..2a22f3bf5db --- /dev/null +++ b/packages/instantsearch.js/src/widgets/chat/__tests__/chat.test.tsx @@ -0,0 +1,60 @@ +/** + * @jest-environment jsdom + */ +/** @jsx h */ +import { createSearchClient } from '@instantsearch/mocks'; +import { wait } from '@instantsearch/testutils'; +import userEvent from '@testing-library/user-event'; + +import instantsearch from '../../../index.es'; +import chat from '../chat'; + +describe('chat', () => { + describe('options', () => { + test('throws without a `container`', () => { + expect(() => { + const searchClient = createSearchClient(); + + const search = instantsearch({ indexName: 'indexName', searchClient }); + + search.addWidgets([ + chat({ + // @ts-expect-error + container: undefined, + }), + ]); + }).toThrowErrorMatchingInlineSnapshot(` + "The \`container\` option is required. + + See documentation: https://www.algolia.com/doc/api-reference/widgets/chat/js/" + `); + }); + + test('adds custom CSS classes', async () => { + const container = document.createElement('div'); + const searchClient = createSearchClient(); + + const search = instantsearch({ indexName: 'indexName', searchClient }); + const widget = chat({ + container, + agentId: 'agentId', + cssClasses: { + root: 'ROOT', + container: 'CONTAINER', + }, + }); + + search.addWidgets([widget]); + search.start(); + await wait(0); + + userEvent.click(container.querySelector('.ais-ChatToggleButton')!); + await wait(0); + + expect(container.querySelector('.ais-Chat')).toHaveClass('ROOT'); + expect(container.querySelector('.ais-Chat-container')).toHaveClass( + 'CONTAINER' + ); + }); + }); +}); diff --git a/packages/instantsearch.js/src/widgets/chat/chat.tsx b/packages/instantsearch.js/src/widgets/chat/chat.tsx new file mode 100644 index 00000000000..9fe6e18d0e4 --- /dev/null +++ b/packages/instantsearch.js/src/widgets/chat/chat.tsx @@ -0,0 +1,342 @@ +/** @jsx h */ + +import { createChatComponent } from 'instantsearch-ui-components'; +import { Fragment, h, render } from 'preact'; + +import TemplateComponent from '../../components/Template/Template'; +import connectChat from '../../connectors/chat/connectChat'; +import { createInsightsEventHandler } from '../../lib/insights/listener'; +import { prepareTemplateProps } from '../../lib/templating'; +import { + getContainerNode, + createDocumentationMessageGenerator, +} from '../../lib/utils'; +import { carousel } from '../../templates'; + +import defaultTemplates from './defaultTemplates'; + +import type { + ChatRenderState, + ChatConnectorParams, + ChatWidgetDescription, +} from '../../connectors/chat/connectChat'; +import type { PreparedTemplateProps } from '../../lib/templating'; +import type { + WidgetFactory, + Renderer, + Hit, + TemplateWithBindEvent, + BaseHit, +} from '../../types'; +import type { ChatClassNames, Tools } from 'instantsearch-ui-components'; + +type ItemComponent = (props: { + item: Record; + onClick?: () => void; + onAuxClick?: () => void; +}) => JSX.Element; + +const withUsage = createDocumentationMessageGenerator({ name: 'chat' }); + +const Chat = createChatComponent({ createElement: h, Fragment }); + +function createDefaultTools = BaseHit>( + itemComponent?: ItemComponent +): Tools { + return [ + { + type: 'tool-algolia_search_index', + component: ({ message, indexUiState, setIndexUiState }) => { + const items = + ( + message.output as { + hits?: Array>; + } + )?.hits || []; + + const input = message.input as { query: string }; + + return ( +
+ {carousel()({ + items, + templates: { item: itemComponent }, + sendEvent: () => {}, + })} + + {input?.query && ( + + )} +
+ ); + }, + onToolCall: ({ toolCall, addToolResult }) => { + addToolResult({ + tool: toolCall.toolName, + toolCallId: toolCall.toolCallId, + output: '', + }); + }, + }, + ]; +} + +const createRenderer = = BaseHit>({ + renderState, + cssClasses, + containerNode, + templates, + tools: userTools = [], +}: { + containerNode: HTMLElement; + cssClasses: ChatCSSClasses; + renderState: { + templateProps?: PreparedTemplateProps>>; + }; + templates: ChatTemplates; + tools?: Tools; +}): Renderer> => { + const state = createLocalState(); + return (props, isFirstRendering) => { + const { + indexUiState, + insights, + input, + instantSearchInstance, + messages, + open, + sendEvent, + sendMessage, + setIndexUiState, + setInput, + setMessages, + setOpen, + status, + } = props; + + if (isFirstRendering) { + renderState.templateProps = prepareTemplateProps({ + defaultTemplates, + templatesConfig: instantSearchInstance.templatesConfig, + templates, + }); + return; + } + + const handleInsightsClick = createInsightsEventHandler({ + insights, + sendEvent, + }); + + const itemComponent: ItemComponent = ({ item, ...rootProps }) => ( + { + handleInsightsClick(event); + rootProps.onClick?.(); + }, + onAuxClick: (event: MouseEvent) => { + handleInsightsClick(event); + rootProps.onAuxClick?.(); + }, + }} + data={item} + sendEvent={sendEvent} + /> + ); + + const hasSearchIndexTool = userTools.some( + (tool) => tool.type === 'tool-algolia_search_index' + ); + + const tools = hasSearchIndexTool + ? userTools + : [...createDefaultTools(itemComponent), ...userTools]; + + state.subscribe(rerender); + + function rerender() { + state.init(); + + const [isClearing, setIsClearing] = state.use(false); + const [maximized, setMaximized] = state.use(false); + + const onClear = () => setIsClearing(true); + const onClearTransitionEnd = () => { + setMessages([]); + setIsClearing(false); + }; + render( + setOpen(false), + onToggleMaximize: () => setMaximized(!maximized), + onClear, + canClear: messages.length > 0 && isClearing !== true, + }} + messagesProps={{ + messages, + indexUiState, + isClearing, + onClearTransitionEnd, + // temporary until we have a good solution in js + // or move logic in ui-component + hideScrollToBottom: true, + setIndexUiState, + tools, + }} + promptProps={{ + status, + value: input, + onInput: (event) => { + setInput(event.currentTarget.value); + }, + onSubmit: () => { + sendMessage({ text: input }); + setInput(''); + }, + }} + toggleButtonProps={{ open, onClick: () => setOpen(!open) }} + />, + containerNode + ); + } + + rerender(); + }; +}; + +export type ChatCSSClasses = Partial; + +export type ChatTemplates = BaseHit> = + Partial<{ + /** + * Template to use for each result. This template will receive an object containing a single record. + */ + item: TemplateWithBindEvent>; + }>; + +type ChatWidgetParams = BaseHit> = { + /** + * CSS Selector or HTMLElement to insert the widget. + */ + container: string | HTMLElement; + + /** + * Client-side tools to add to the chat + */ + tools?: Tools; + + /** + * Templates to use for the widget. + */ + templates?: ChatTemplates; + + /** + * CSS classes to add. + */ + cssClasses?: ChatCSSClasses; +}; + +export type ChatWidget = WidgetFactory< + ChatWidgetDescription & { $$widgetType: 'ais.chat' }, + ChatConnectorParams, + ChatWidgetParams +>; + +export default (function chat = BaseHit>( + widgetParams: ChatWidgetParams & ChatConnectorParams +) { + const { + container, + templates = {}, + cssClasses = {}, + resume = false, + tools, + ...options + } = widgetParams || {}; + + if (!container) { + throw new Error(withUsage('The `container` option is required.')); + } + + const containerNode = getContainerNode(container); + + const specializedRenderer = createRenderer({ + containerNode, + cssClasses, + renderState: {}, + templates, + tools, + }); + + const makeWidget = connectChat(specializedRenderer, () => + render(null, containerNode) + ); + + return { + ...makeWidget({ + resume, + ...options, + }), + $$widgetType: 'ais.chat', + }; +} satisfies ChatWidget); + +function createLocalState() { + const state: unknown[] = []; + const subscriptions = new Set<() => void>(); + let cursor = 0; + + function use(initialValue: T): [T, (value: T) => T] { + const index = cursor++; + if (state[index] === undefined) { + state[index] = initialValue; + } + + return [ + state[index] as T, + (value: T) => { + const prev = state[index] as T; + if (prev === value) { + return prev; + } + + state[index] = value; + subscriptions.forEach((fn) => fn()); + return value; + }, + ]; + } + + return { + init() { + cursor = 0; + }, + subscribe(fn: () => void): () => void { + subscriptions.add(fn); + + return () => subscriptions.delete(fn); + }, + use, + }; +} diff --git a/packages/instantsearch.js/src/widgets/chat/defaultTemplates.ts b/packages/instantsearch.js/src/widgets/chat/defaultTemplates.ts new file mode 100644 index 00000000000..a3a75ef871b --- /dev/null +++ b/packages/instantsearch.js/src/widgets/chat/defaultTemplates.ts @@ -0,0 +1,9 @@ +import { ChatTemplates } from './chat'; + +const defaultTemplates = { + item(data) { + return JSON.stringify(data, null, 2); + }, +} satisfies ChatTemplates; + +export default defaultTemplates; diff --git a/packages/instantsearch.js/src/widgets/index.ts b/packages/instantsearch.js/src/widgets/index.ts index 6fd492c5200..e98fbd39ebb 100644 --- a/packages/instantsearch.js/src/widgets/index.ts +++ b/packages/instantsearch.js/src/widgets/index.ts @@ -58,3 +58,4 @@ export { default as trendingItems } from './trending-items/trending-items'; export { default as voiceSearch } from './voice-search/voice-search'; export { default as frequentlyBoughtTogether } from './frequently-bought-together/frequently-bought-together'; export { default as lookingSimilar } from './looking-similar/looking-similar'; +export { default as chat } from './chat/chat'; diff --git a/packages/instantsearch.js/src/widgets/index.umd.ts b/packages/instantsearch.js/src/widgets/index.umd.ts new file mode 100644 index 00000000000..a9ba98e86e4 --- /dev/null +++ b/packages/instantsearch.js/src/widgets/index.umd.ts @@ -0,0 +1,69 @@ +import { deprecate } from '../lib/utils'; + +import answers from './answers/answers'; +import configureRelatedItems from './configure-related-items/configure-related-items'; +import dynamicWidgets from './dynamic-widgets/dynamic-widgets'; + +/** @deprecated answers is no longer supported */ +export const EXPERIMENTAL_answers = deprecate( + answers, + 'answers is no longer supported' +); + +/** @deprecated use relatedItems instead */ +export const EXPERIMENTAL_configureRelatedItems = deprecate( + configureRelatedItems, + 'EXPERIMENTAL_configureRelatedItems is deprecated and will be removed in a next minor version of InstantSearch. Please use relatedItems instead.' +); + +/** @deprecated use dynamicWidgets */ +export const EXPERIMENTAL_dynamicWidgets = deprecate( + dynamicWidgets, + 'use dynamicWidgets' +); +export { dynamicWidgets }; + +export { default as analytics } from './analytics/analytics'; +export { default as breadcrumb } from './breadcrumb/breadcrumb'; +export { default as clearRefinements } from './clear-refinements/clear-refinements'; +export { default as configure } from './configure/configure'; +export { default as currentRefinements } from './current-refinements/current-refinements'; +export { default as geoSearch } from './geo-search/geo-search'; +export { default as hierarchicalMenu } from './hierarchical-menu/hierarchical-menu'; +export { default as hits } from './hits/hits'; +export { default as hitsPerPage } from './hits-per-page/hits-per-page'; +export { default as index } from './index/index'; +export type { IndexWidget } from './index/index'; +export { default as infiniteHits } from './infinite-hits/infinite-hits'; +export { default as menu } from './menu/menu'; +export { default as menuSelect } from './menu-select/menu-select'; +export { default as numericMenu } from './numeric-menu/numeric-menu'; +export { default as pagination } from './pagination/pagination'; +export { default as panel } from './panel/panel'; +export { default as places } from './places/places'; +export { default as poweredBy } from './powered-by/powered-by'; +export { default as queryRuleContext } from './query-rule-context/query-rule-context'; +export { default as queryRuleCustomData } from './query-rule-custom-data/query-rule-custom-data'; +export { default as relatedProducts } from './related-products/related-products'; +export { default as rangeInput } from './range-input/range-input'; +export { default as rangeSlider } from './range-slider/range-slider'; +export { default as ratingMenu } from './rating-menu/rating-menu'; +export { default as refinementList } from './refinement-list/refinement-list'; +export { default as relevantSort } from './relevant-sort/relevant-sort'; +export { default as searchBox } from './search-box/search-box'; +export { default as sortBy } from './sort-by/sort-by'; +export { default as stats } from './stats/stats'; +export { default as toggleRefinement } from './toggle-refinement/toggle-refinement'; +export { default as trendingItems } from './trending-items/trending-items'; +export { default as voiceSearch } from './voice-search/voice-search'; +export { default as frequentlyBoughtTogether } from './frequently-bought-together/frequently-bought-together'; +export { default as lookingSimilar } from './looking-similar/looking-similar'; + +export const chat = () => { + throw new Error( + `"chat" is not available from the UMD build. + +Please use InstantSearch.js with a packaging system: +https://www.algolia.com/doc/guides/building-search-ui/installation/js/#with-a-packaging-system` + ); +}; diff --git a/packages/instantsearch.js/stories/breadcrumb.stories.ts b/packages/instantsearch.js/stories/breadcrumb.stories.ts index a57e5ccd147..da4408a697a 100644 --- a/packages/instantsearch.js/stories/breadcrumb.stories.ts +++ b/packages/instantsearch.js/stories/breadcrumb.stories.ts @@ -1,7 +1,7 @@ import { storiesOf } from '@storybook/html'; import { withHits, withLifecycle } from '../.storybook/decorators'; -import { connectHierarchicalMenu } from '../src/connectors'; +import connectHierarchicalMenu from '../src/connectors/hierarchical-menu/connectHierarchicalMenu'; import { noop } from '../src/lib/utils'; const virtualHierarchicalMenu = (args = {}) => diff --git a/packages/instantsearch.js/stories/panel.stories.ts b/packages/instantsearch.js/stories/panel.stories.ts index 068b86206c2..296a648da9a 100644 --- a/packages/instantsearch.js/stories/panel.stories.ts +++ b/packages/instantsearch.js/stories/panel.stories.ts @@ -1,7 +1,7 @@ import { storiesOf } from '@storybook/html'; import { withHits } from '../.storybook/decorators'; -import { connectHierarchicalMenu } from '../src/connectors'; +import connectHierarchicalMenu from '../src/connectors/hierarchical-menu/connectHierarchicalMenu'; import { noop } from '../src/lib/utils'; const virtualHierarchicalMenu = (args = {}) => diff --git a/packages/react-instantsearch/src/__tests__/common-connectors.test.tsx b/packages/react-instantsearch/src/__tests__/common-connectors.test.tsx index 6af30f7d287..8cc848e7223 100644 --- a/packages/react-instantsearch/src/__tests__/common-connectors.test.tsx +++ b/packages/react-instantsearch/src/__tests__/common-connectors.test.tsx @@ -435,6 +435,7 @@ const testSetups: TestSetupsMap = { render(); }, + createChatConnectorTests: () => {}, }; const testOptions: TestOptionsMap = { @@ -458,6 +459,7 @@ const testOptions: TestOptionsMap = { createFrequentlyBoughtTogetherConnectorTests: { act }, createTrendingItemsConnectorTests: { act }, createLookingSimilarConnectorTests: { act, skippedTests: { options: true } }, + createChatConnectorTests: { act, skippedTests: { options: true } }, }; describe('Common connector tests (React InstantSearch)', () => { diff --git a/packages/react-instantsearch/src/__tests__/common-widgets.test.tsx b/packages/react-instantsearch/src/__tests__/common-widgets.test.tsx index 65a8b8797df..de21489f8d4 100644 --- a/packages/react-instantsearch/src/__tests__/common-widgets.test.tsx +++ b/packages/react-instantsearch/src/__tests__/common-widgets.test.tsx @@ -392,6 +392,9 @@ const testSetups: TestSetupsMap = { ); }, + createChatWidgetTests() { + throw new Error('Chat is not tested through the Common Test Suite yet'); + }, }; const testOptions: TestOptionsMap = { @@ -435,6 +438,12 @@ const testOptions: TestOptionsMap = { }, }, createDynamicWidgetsWidgetTests: { act }, + createChatWidgetTests: { + act, + skippedTests: { + 'Chat widget common tests': true, + }, + }, }; /** diff --git a/packages/vue-instantsearch/src/__tests__/common-connectors.test.js b/packages/vue-instantsearch/src/__tests__/common-connectors.test.js index 85f6b825273..0db68d398c8 100644 --- a/packages/vue-instantsearch/src/__tests__/common-connectors.test.js +++ b/packages/vue-instantsearch/src/__tests__/common-connectors.test.js @@ -14,7 +14,7 @@ import { connectRatingMenu, connectRefinementList, connectToggleRefinement, -} from 'instantsearch.js/es/connectors'; +} from 'instantsearch.js/es/connectors/index.umd'; import { nextTick, mountApp } from '../../test/utils'; import { @@ -343,6 +343,7 @@ const testSetups = { createFrequentlyBoughtTogetherConnectorTests: () => {}, createTrendingItemsConnectorTests: () => {}, createLookingSimilarConnectorTests: () => {}, + createChatConnectorTests: () => {}, }; function createCustomWidget({ @@ -441,6 +442,7 @@ const testOptions = { state: true, }, }, + createChatConnectorTests: { skippedTests: { options: true } }, }; describe('Common connector tests (Vue InstantSearch)', () => { diff --git a/packages/vue-instantsearch/src/__tests__/common-shared.test.js b/packages/vue-instantsearch/src/__tests__/common-shared.test.js index 97a7371a71b..87b1e8f11c0 100644 --- a/packages/vue-instantsearch/src/__tests__/common-shared.test.js +++ b/packages/vue-instantsearch/src/__tests__/common-shared.test.js @@ -3,7 +3,10 @@ */ import { runTestSuites } from '@instantsearch/tests/common'; import * as testSuites from '@instantsearch/tests/shared'; -import { connectMenu, connectPagination } from 'instantsearch.js/es/connectors'; +import { + connectMenu, + connectPagination, +} from 'instantsearch.js/es/connectors/index.umd'; import { nextTick, mountApp } from '../../test/utils'; import { diff --git a/packages/vue-instantsearch/src/__tests__/common-widgets.test.js b/packages/vue-instantsearch/src/__tests__/common-widgets.test.js index 6333104c446..60b5fcd4a73 100644 --- a/packages/vue-instantsearch/src/__tests__/common-widgets.test.js +++ b/packages/vue-instantsearch/src/__tests__/common-widgets.test.js @@ -581,6 +581,9 @@ const testSetups = { document.body.appendChild(document.createElement('div')) ); }, + createChatWidgetTests() { + throw new Error('Chat is not supported in Vue InstantSearch'); + }, }; const testOptions = { @@ -623,6 +626,9 @@ const testOptions = { }, createPoweredByWidgetTests: undefined, createDynamicWidgetsWidgetTests: undefined, + createChatWidgetTests: { + skippedTests: { 'Chat widget common tests': true }, + }, }; describe('Common widget tests (Vue InstantSearch)', () => { diff --git a/packages/vue-instantsearch/src/components/Autocomplete.vue b/packages/vue-instantsearch/src/components/Autocomplete.vue index accf72f0f93..994f9b136ae 100644 --- a/packages/vue-instantsearch/src/components/Autocomplete.vue +++ b/packages/vue-instantsearch/src/components/Autocomplete.vue @@ -20,7 +20,7 @@