diff --git a/packages/runtime/src/experiences/types.ts b/packages/runtime/src/experiences/types.ts index 5652165..6303fe3 100644 --- a/packages/runtime/src/experiences/types.ts +++ b/packages/runtime/src/experiences/types.ts @@ -4,6 +4,7 @@ import type { Widget, } from 'instantsearch.js/es/types'; import type { ChatWidget } from 'instantsearch.js/es/widgets/chat/chat'; +import type { SearchBoxWidget } from 'instantsearch.js/es/widgets/search-box/search-box'; export type Environment = 'prod' | 'beta'; @@ -44,5 +45,9 @@ export type ExperienceWidget = Widget & { $$supportedWidgets: { 'ais.chat': SupportedWidget[0]>; 'ais.autocomplete': SupportedWidget; - } & Record<'ais.chat' | 'ais.autocomplete' | (string & {}), SupportedWidget>; + 'ais.searchBox': SupportedWidget[0]>; + } & Record< + 'ais.chat' | 'ais.autocomplete' | 'ais.searchBox' | (string & {}), + SupportedWidget + >; }; diff --git a/packages/runtime/src/experiences/widget.tsx b/packages/runtime/src/experiences/widget.tsx index ecae85b..e897760 100644 --- a/packages/runtime/src/experiences/widget.tsx +++ b/packages/runtime/src/experiences/widget.tsx @@ -5,6 +5,7 @@ import { import { getExperience } from './get-experience'; import chat from 'instantsearch.js/es/widgets/chat/chat'; import { EXPERIMENTAL_autocomplete } from 'instantsearch.js/es/widgets/autocomplete/autocomplete'; +import searchBox from 'instantsearch.js/es/widgets/search-box/search-box'; import { renderTemplate, renderTool } from './renderer'; import type { ExperienceWidget } from './types'; @@ -127,6 +128,14 @@ export default (function experience(widgetParams: ExperienceWidgetParams) { }); }, }, + // TODO: Add support for `templates` (submit, reset, loadingIndicator) + // TODO: Add support for `queryHook` (bucket 3 — function) + 'ais.searchBox': { + widget: searchBox, + async transformParams(params) { + return params; + }, + }, }, render: () => {}, dispose: () => {}, diff --git a/packages/toolbar/__tests__/ai-tools.test.ts b/packages/toolbar/__tests__/ai-tools.test.ts index 104d293..86ba56b 100644 --- a/packages/toolbar/__tests__/ai-tools.test.ts +++ b/packages/toolbar/__tests__/ai-tools.test.ts @@ -30,6 +30,7 @@ describe('describeWidgetTypes', () => { expect(result).toContain('Autocomplete'); expect(result).toContain('ais.chat'); expect(result).toContain('Chat'); + expect(result).toContain('ais.searchBox'); }); it('includes widget descriptions and parameter descriptions', () => { @@ -51,7 +52,6 @@ describe('describeWidgetTypes', () => { it('excludes disabled widget types', () => { const result = describeWidgetTypes(); expect(result).not.toContain('ais.hits'); - expect(result).not.toContain('ais.searchBox'); expect(result).not.toContain('ais.pagination'); }); }); @@ -380,7 +380,11 @@ describe('getTools', () => { type: 'ais.autocomplete', container: '#search', placement: 'before', - parameters: { container: '#other', placement: 'after', showRecent: true }, + parameters: { + container: '#other', + placement: 'after', + showRecent: true, + }, }, { toolCallId: 'tc1', messages: [] } ); @@ -429,6 +433,38 @@ describe('getTools', () => { expect(callbacks.onAddBlock).toHaveBeenCalledWith('ais.autocomplete'); }); + it('adds searchBox with default inside placement and container', async () => { + const experience: ExperienceApiResponse = { blocks: [] }; + const callbacks = createCallbacks(experience); + const tools = getTools(callbacks); + + const result = await tools.add_widget.execute!( + { type: 'ais.searchBox', container: '#search-box' }, + { toolCallId: 'tc1', messages: [] } + ); + + expect(callbacks.onAddBlock).toHaveBeenCalledWith('ais.searchBox'); + expect(callbacks.onParameterChange).toHaveBeenCalledWith( + 0, + 'placement', + 'inside' + ); + expect(callbacks.onParameterChange).toHaveBeenCalledWith( + 0, + 'container', + '#search-box' + ); + expect(result).toMatchObject({ + success: true, + index: 0, + type: 'ais.searchBox', + placement: 'inside', + container: '#search-box', + applied: ['placement', 'container'], + rejected: [], + }); + }); + it('computes the correct index for non-empty experiences', async () => { const experience: ExperienceApiResponse = { blocks: [ @@ -656,7 +692,10 @@ describe('getTools', () => { expect(result).toMatchObject({ success: true, - applied: ['cssVariables.primary-color-rgb', 'cssVariables.secondary-color'], + applied: [ + 'cssVariables.primary-color-rgb', + 'cssVariables.secondary-color', + ], }); expect(callbacks.onCssVariableChange).toHaveBeenCalledTimes(2); }); @@ -757,6 +796,69 @@ describe('getTools', () => { error: expect.stringContaining('indices 0–1'), }); }); + + it('applies a boolean parameter with switch override on searchBox', async () => { + const experience: ExperienceApiResponse = { + blocks: [ + { + type: 'ais.searchBox', + parameters: { container: '#search-box', searchAsYouType: true }, + }, + ], + }; + const callbacks = createCallbacks(experience); + const tools = getTools(callbacks); + + const result = await tools.edit_widget.execute!( + { index: 0, parameters: { searchAsYouType: false } }, + { toolCallId: 'tc1', messages: [] } + ); + + expect(result).toMatchObject({ + success: true, + applied: ['searchAsYouType'], + rejected: [], + }); + expect(callbacks.onParameterChange).toHaveBeenCalledWith( + 0, + 'searchAsYouType', + false + ); + }); + + it('applies an object parameter (cssClasses) on searchBox', async () => { + const experience: ExperienceApiResponse = { + blocks: [ + { + type: 'ais.searchBox', + parameters: { container: '#search-box', cssClasses: false }, + }, + ], + }; + const callbacks = createCallbacks(experience); + const tools = getTools(callbacks); + + const result = await tools.edit_widget.execute!( + { + index: 0, + parameters: { + cssClasses: { root: 'my-root', input: 'my-input' }, + }, + }, + { toolCallId: 'tc1', messages: [] } + ); + + expect(result).toMatchObject({ + success: true, + applied: ['cssClasses'], + rejected: [], + }); + expect(callbacks.onParameterChange).toHaveBeenCalledWith( + 0, + 'cssClasses', + { root: 'my-root', input: 'my-input' } + ); + }); }); describe('remove_widget', () => { diff --git a/packages/toolbar/src/toolbar.css b/packages/toolbar/src/toolbar.css index a3d36f2..5408d7e 100644 --- a/packages/toolbar/src/toolbar.css +++ b/packages/toolbar/src/toolbar.css @@ -127,7 +127,8 @@ background: none; padding: 0; } - ul, ol { + ul, + ol { margin: 0.25em 0; padding-left: 1.25em; } diff --git a/packages/toolbar/src/widget-types.tsx b/packages/toolbar/src/widget-types.tsx index d1067ef..68143c1 100644 --- a/packages/toolbar/src/widget-types.tsx +++ b/packages/toolbar/src/widget-types.tsx @@ -292,10 +292,87 @@ export const WIDGET_TYPES: Record = { }, 'ais.searchBox': { label: 'Search Box', - enabled: false, + enabled: true, + description: 'A search input with submit, reset, and loading indicators.', icon: SEARCH_ICON, defaultParameters: { container: '', + placeholder: '', + autofocus: false, + showLoadingIndicator: true, + showSubmit: true, + showReset: true, + searchAsYouType: true, + ignoreCompositionEvents: false, + cssClasses: false, + }, + fieldOrder: [ + 'container', + 'placement', + 'placeholder', + 'autofocus', + 'searchAsYouType', + 'ignoreCompositionEvents', + 'showLoadingIndicator', + 'showSubmit', + 'showReset', + 'cssClasses', + ], + fieldOverrides: { + autofocus: { type: 'switch', label: 'Autofocus' }, + searchAsYouType: { type: 'switch', label: 'Search as you type' }, + ignoreCompositionEvents: { + type: 'switch', + label: 'Ignore composition events', + }, + showLoadingIndicator: { type: 'switch', label: 'Show loading indicator' }, + showSubmit: { type: 'switch', label: 'Show submit button' }, + showReset: { type: 'switch', label: 'Show reset button' }, + cssClasses: { + type: 'object', + label: 'CSS classes', + defaultValue: { + root: '', + form: '', + input: '', + submit: '', + submitIcon: '', + reset: '', + resetIcon: '', + loadingIndicator: '', + loadingIcon: '', + }, + fields: [ + { key: 'root', label: 'Root' }, + { key: 'form', label: 'Form' }, + { key: 'input', label: 'Input' }, + { key: 'submit', label: 'Submit' }, + { key: 'submitIcon', label: 'Submit Icon' }, + { key: 'reset', label: 'Reset' }, + { key: 'resetIcon', label: 'Reset Icon' }, + { key: 'loadingIndicator', label: 'Loading Indicator' }, + { key: 'loadingIcon', label: 'Loading Icon' }, + ], + }, + }, + paramLabels: { + container: 'Container', + placeholder: 'Placeholder', + }, + paramDescriptions: { + container: + 'CSS selector for the DOM element to render into (e.g. "#search-box").', + placeholder: 'Placeholder text shown in the search input.', + autofocus: 'Whether the input should be focused on page load.', + searchAsYouType: + 'When enabled, triggers a search on each keystroke. When disabled, searches only on submit.', + ignoreCompositionEvents: + 'When enabled, ignores IME composition events for CJK input.', + showLoadingIndicator: + 'Whether to show a loading indicator while results are being fetched.', + showSubmit: 'Whether to show the submit button.', + showReset: 'Whether to show the reset button.', + cssClasses: 'CSS classes to apply to the widget DOM elements.', }, }, 'ais.refinementList': {