diff --git a/packages/runtime/src/experiences/types.ts b/packages/runtime/src/experiences/types.ts index 5652165..37f1d1d 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 { HitsWidget } from 'instantsearch.js/es/widgets/hits/hits'; 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.hits': SupportedWidget[0]>; + } & Record< + 'ais.chat' | 'ais.autocomplete' | 'ais.hits' | (string & {}), + SupportedWidget + >; }; diff --git a/packages/runtime/src/experiences/widget.tsx b/packages/runtime/src/experiences/widget.tsx index ecae85b..d680d51 100644 --- a/packages/runtime/src/experiences/widget.tsx +++ b/packages/runtime/src/experiences/widget.tsx @@ -4,6 +4,7 @@ import { } from 'instantsearch.js/es/lib/utils'; import { getExperience } from './get-experience'; import chat from 'instantsearch.js/es/widgets/chat/chat'; +import hits from 'instantsearch.js/es/widgets/hits/hits'; import { EXPERIMENTAL_autocomplete } from 'instantsearch.js/es/widgets/autocomplete/autocomplete'; import { renderTemplate, renderTool } from './renderer'; @@ -127,6 +128,14 @@ export default (function experience(widgetParams: ExperienceWidgetParams) { }); }, }, + // TODO: Add support for `templates` (item, empty, banner) + // TODO: Add support for `transformItems` (bucket 3 function) + 'ais.hits': { + widget: hits, + async transformParams(parameters) { + return parameters; + }, + }, }, render: () => {}, dispose: () => {}, diff --git a/packages/toolbar/__tests__/ai-tools.test.ts b/packages/toolbar/__tests__/ai-tools.test.ts index 2006235..c8153f8 100644 --- a/packages/toolbar/__tests__/ai-tools.test.ts +++ b/packages/toolbar/__tests__/ai-tools.test.ts @@ -30,6 +30,8 @@ describe('describeWidgetTypes', () => { expect(result).toContain('Autocomplete'); expect(result).toContain('ais.chat'); expect(result).toContain('Chat'); + expect(result).toContain('ais.hits'); + expect(result).toContain('Hits'); }); it('includes widget descriptions and parameter descriptions', () => { @@ -38,6 +40,9 @@ describe('describeWidgetTypes', () => { expect(result).toContain('Parameters:'); expect(result).toContain('showRecent'); expect(result).toContain('recent searches'); + expect(result).toContain('search results'); + expect(result).toContain('escapeHTML'); + expect(result).toContain('XSS'); }); it('includes default placement per widget type', () => { @@ -46,11 +51,11 @@ describe('describeWidgetTypes', () => { 'ais.autocomplete ("Autocomplete", default placement: inside)' ); expect(result).toContain('ais.chat ("Chat", default placement: body)'); + expect(result).toContain('ais.hits ("Hits", default placement: inside)'); }); 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'); }); @@ -433,6 +438,38 @@ describe('getTools', () => { expect(callbacks.onAddBlock).toHaveBeenCalledWith('ais.autocomplete'); }); + it('adds hits 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.hits', container: '#hits' }, + { toolCallId: 'tc1', messages: [] } + ); + + expect(callbacks.onAddBlock).toHaveBeenCalledWith('ais.hits'); + expect(callbacks.onParameterChange).toHaveBeenCalledWith( + 0, + 'placement', + 'inside' + ); + expect(callbacks.onParameterChange).toHaveBeenCalledWith( + 0, + 'container', + '#hits' + ); + expect(result).toMatchObject({ + success: true, + index: 0, + type: 'ais.hits', + placement: 'inside', + container: '#hits', + applied: ['placement', 'container'], + rejected: [], + }); + }); + it('computes the correct index for non-empty experiences', async () => { const experience: ExperienceApiResponse = { blocks: [ @@ -738,6 +775,69 @@ describe('getTools', () => { expect(callbacks.onParameterChange).not.toHaveBeenCalled(); }); + it('applies a boolean parameter with switch override on hits', async () => { + const experience: ExperienceApiResponse = { + blocks: [ + { + type: 'ais.hits', + parameters: { container: '#hits', escapeHTML: true }, + }, + ], + }; + const callbacks = createCallbacks(experience); + const tools = getTools(callbacks); + + const result = await tools.edit_widget.execute!( + { index: 0, parameters: { escapeHTML: false } }, + { toolCallId: 'tc1', messages: [] } + ); + + expect(result).toMatchObject({ + success: true, + applied: ['escapeHTML'], + rejected: [], + }); + expect(callbacks.onParameterChange).toHaveBeenCalledWith( + 0, + 'escapeHTML', + false + ); + }); + + it('applies an object parameter (cssClasses) on hits', async () => { + const experience: ExperienceApiResponse = { + blocks: [ + { + type: 'ais.hits', + parameters: { container: '#hits' }, + }, + ], + }; + const callbacks = createCallbacks(experience); + const tools = getTools(callbacks); + + const result = await tools.edit_widget.execute!( + { + index: 0, + parameters: { + cssClasses: { root: 'my-root', item: 'my-item' }, + }, + }, + { toolCallId: 'tc1', messages: [] } + ); + + expect(result).toMatchObject({ + success: true, + applied: ['cssClasses'], + rejected: [], + }); + expect(callbacks.onParameterChange).toHaveBeenCalledWith( + 0, + 'cssClasses', + { root: 'my-root', item: 'my-item' } + ); + }); + it('includes index range in bounds error message', async () => { const experience: ExperienceApiResponse = { blocks: [ diff --git a/packages/toolbar/__tests__/hits.test.tsx b/packages/toolbar/__tests__/hits.test.tsx new file mode 100644 index 0000000..ed1c25b --- /dev/null +++ b/packages/toolbar/__tests__/hits.test.tsx @@ -0,0 +1,88 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { + fireInput, + getInput, + getSwitch, + renderEditor, +} from './widget-test-utils'; + +afterEach(() => { + document.body.innerHTML = ''; +}); + +function render(params: Record = {}) { + return renderEditor(params, 'ais.hits'); +} + +describe('ais.hits field behavior', () => { + describe('switch field (escapeHTML)', () => { + it('sends false when toggled off', () => { + const { onParameterChange, container } = render({ + escapeHTML: true, + }); + const toggle = getSwitch(container, 'Escape HTML'); + + toggle.click(); + + expect(onParameterChange).toHaveBeenCalledWith('escapeHTML', false); + }); + + it('sends true when toggled on', () => { + const { onParameterChange, container } = render({ + escapeHTML: false, + }); + const toggle = getSwitch(container, 'Escape HTML'); + + toggle.click(); + + expect(onParameterChange).toHaveBeenCalledWith('escapeHTML', true); + }); + }); + + describe('object field with disabledValue undefined (cssClasses)', () => { + it('renders the toggle even when cssClasses is absent from parameters', () => { + const { container } = render(); + const toggle = getSwitch(container, 'CSS classes'); + + expect(toggle).not.toBeNull(); + }); + + it('sends the default object when toggled on', () => { + const { onParameterChange, container } = render(); + const toggle = getSwitch(container, 'CSS classes'); + + toggle.click(); + + expect(onParameterChange).toHaveBeenCalledWith( + 'cssClasses', + expect.objectContaining({ root: '', list: '', item: '' }) + ); + }); + + it('sends undefined (not false) when toggled off', () => { + const { onParameterChange, container } = render({ + cssClasses: { root: 'my-root' }, + }); + const toggle = getSwitch(container, 'CSS classes'); + + toggle.click(); + + expect(onParameterChange).toHaveBeenCalledWith('cssClasses', undefined); + }); + + it('sends the updated object when a sub-field changes', () => { + const { onParameterChange, container } = render({ + cssClasses: { root: '', list: '' }, + }); + const input = getInput(container, 'Root'); + + fireInput(input, 'my-root'); + + expect(onParameterChange).toHaveBeenCalledWith( + 'cssClasses', + expect.objectContaining({ root: 'my-root' }) + ); + }); + }); +}); diff --git a/packages/toolbar/__tests__/toolbar.test.ts b/packages/toolbar/__tests__/toolbar.test.ts index c7b8fdd..538f975 100644 --- a/packages/toolbar/__tests__/toolbar.test.ts +++ b/packages/toolbar/__tests__/toolbar.test.ts @@ -218,18 +218,18 @@ describe('toolbar', () => { }); // Disabled items are rendered as
elements, not