diff --git a/packages/runtime/src/experiences/types.ts b/packages/runtime/src/experiences/types.ts index 44adbca..4ac221c 100644 --- a/packages/runtime/src/experiences/types.ts +++ b/packages/runtime/src/experiences/types.ts @@ -6,6 +6,7 @@ import type { import type { ChatWidget } from 'instantsearch.js/es/widgets/chat/chat'; import type { ConfigureWidget } from 'instantsearch.js/es/widgets/configure/configure'; import type { HitsWidget } from 'instantsearch.js/es/widgets/hits/hits'; +import type { InfiniteHitsWidget } from 'instantsearch.js/es/widgets/infinite-hits/infinite-hits'; import type { SearchBoxWidget } from 'instantsearch.js/es/widgets/search-box/search-box'; export type Environment = 'prod' | 'beta'; @@ -57,12 +58,14 @@ export type ExperienceWidget = Widget & { 'ais.configure': SupportedWidget[0]>; 'ais.autocomplete': SupportedWidget; 'ais.hits': SupportedWidget[0]>; + 'ais.infiniteHits': SupportedWidget[0]>; 'ais.searchBox': SupportedWidget[0]>; } & Record< | 'ais.chat' | 'ais.configure' | 'ais.autocomplete' | 'ais.hits' + | 'ais.infiniteHits' | 'ais.searchBox' | (string & {}), SupportedWidget diff --git a/packages/runtime/src/experiences/widget.tsx b/packages/runtime/src/experiences/widget.tsx index 73e564e..ba0f858 100644 --- a/packages/runtime/src/experiences/widget.tsx +++ b/packages/runtime/src/experiences/widget.tsx @@ -6,6 +6,7 @@ import { getExperience } from './get-experience'; import chat from 'instantsearch.js/es/widgets/chat/chat'; import configure from 'instantsearch.js/es/widgets/configure/configure'; import hits from 'instantsearch.js/es/widgets/hits/hits'; +import infiniteHits from 'instantsearch.js/es/widgets/infinite-hits/infinite-hits'; import { EXPERIMENTAL_autocomplete } from 'instantsearch.js/es/widgets/autocomplete/autocomplete'; import searchBox from 'instantsearch.js/es/widgets/search-box/search-box'; @@ -149,6 +150,15 @@ export default (function experience(widgetParams: ExperienceWidgetParams) { return parameters; }, }, + // TODO: Add support for `templates` (item, empty, showMoreText) + // TODO: Add support for `transformItems` (bucket 3 function) + // TODO: Add support for `cache` (bucket 3 function) + 'ais.infiniteHits': { + widget: infiniteHits, + async transformParams(parameters) { + return parameters; + }, + }, // TODO: Add support for `templates` (submit, reset, loadingIndicator) // TODO: Add support for `queryHook` (bucket 3 — function) 'ais.searchBox': { diff --git a/packages/toolbar/__tests__/ai-tools.test.ts b/packages/toolbar/__tests__/ai-tools.test.ts index 56e4ce1..e071a44 100644 --- a/packages/toolbar/__tests__/ai-tools.test.ts +++ b/packages/toolbar/__tests__/ai-tools.test.ts @@ -41,6 +41,8 @@ describe('describeWidgetTypes', () => { expect(result).toContain('Configure'); expect(result).toContain('ais.hits'); expect(result).toContain('Hits'); + expect(result).toContain('ais.infiniteHits'); + expect(result).toContain('Infinite Hits'); expect(result).toContain('ais.searchBox'); expect(result).toContain('Search Box'); }); @@ -640,6 +642,130 @@ describe('getTools', () => { }); it('adds ais.index widget at top level', async () => { + const experience: ExperienceApiResponse = { + blocks: [], + indexName: '', + }; + const callbacks = createCallbacks(experience); + const tools = getTools(callbacks); + + const result = await tools.add_widget.execute!( + { type: 'ais.index', parameters: { indexName: 'products' } }, + { toolCallId: 'tc1', messages: [] } + ); + + expect(callbacks.onAddBlock).toHaveBeenCalledWith('ais.index', undefined); + expect(result).toMatchObject({ + success: true, + type: 'ais.index', + }); + }); + + it('skips container and placement inside parameters to avoid duplication', async () => { + const experience: ExperienceApiResponse = { + blocks: [], + indexName: '', + }; + const callbacks = createCallbacks(experience); + const tools = getTools(callbacks); + + await tools.add_widget.execute!( + { + type: 'ais.autocomplete', + container: '#search', + placement: 'before', + parameters: { + container: '#other', + placement: 'after', + showRecent: true, + }, + }, + { toolCallId: 'tc1', messages: [] } + ); + + // Top-level container and placement should be used + expect(callbacks.onParameterChange).toHaveBeenCalledWith( + [0], + 'container', + '#search' + ); + expect(callbacks.onParameterChange).toHaveBeenCalledWith( + [0], + 'placement', + 'before' + ); + // container and placement inside parameters should be skipped + expect(callbacks.onParameterChange).not.toHaveBeenCalledWith( + [0], + 'container', + '#other' + ); + expect(callbacks.onParameterChange).not.toHaveBeenCalledWith( + [0], + 'placement', + 'after' + ); + // Other params inside parameters should still be applied + expect(callbacks.onParameterChange).toHaveBeenCalledWith( + [0], + 'showRecent', + true + ); + }); + + it('adds an infinite hits widget with default parameters', async () => { + const experience: ExperienceApiResponse = { + blocks: [], + indexName: '', + }; + const callbacks = createCallbacks(experience); + const tools = getTools(callbacks); + + const result = await tools.add_widget.execute!( + { type: 'ais.infiniteHits', container: '#hits' }, + { toolCallId: 'tc1', messages: [] } + ); + + expect(callbacks.onAddBlock).toHaveBeenCalledWith( + 'ais.infiniteHits', + undefined + ); + expect(callbacks.onParameterChange).toHaveBeenCalledWith( + [0], + 'container', + '#hits' + ); + expect(result).toMatchObject({ + success: true, + type: 'ais.infiniteHits', + }); + }); + + it('adds an infinite hits widget with showPrevious enabled', async () => { + const experience: ExperienceApiResponse = { + blocks: [], + indexName: '', + }; + const callbacks = createCallbacks(experience); + const tools = getTools(callbacks); + + await tools.add_widget.execute!( + { + type: 'ais.infiniteHits', + container: '#hits', + parameters: { showPrevious: true }, + }, + { toolCallId: 'tc1', messages: [] } + ); + + expect(callbacks.onParameterChange).toHaveBeenCalledWith( + [0], + 'showPrevious', + true + ); + }); + + it('computes the correct index for non-empty experiences', async () => { const experience: ExperienceApiResponse = { blocks: [ { @@ -1176,6 +1302,67 @@ describe('getTools', () => { ); }); + it('edits infinite hits escapeHTML parameter', async () => { + const experience: ExperienceApiResponse = { + blocks: [ + { + type: 'ais.infiniteHits', + parameters: { container: '#hits', escapeHTML: true }, + }, + ], + indexName: '', + }; + const callbacks = createCallbacks(experience); + const tools = getTools(callbacks); + + const result = await tools.edit_widget.execute!( + { path: '0', parameters: { escapeHTML: false } }, + { toolCallId: 'tc1', messages: [] } + ); + + expect(result).toMatchObject({ + success: true, + applied: ['escapeHTML'], + }); + expect(callbacks.onParameterChange).toHaveBeenCalledWith( + [0], + 'escapeHTML', + false + ); + }); + + it('edits infinite hits cssClasses parameter', async () => { + const experience: ExperienceApiResponse = { + blocks: [ + { + type: 'ais.infiniteHits', + parameters: { container: '#hits' }, + }, + ], + indexName: '', + }; + const callbacks = createCallbacks(experience); + const tools = getTools(callbacks); + + const result = await tools.edit_widget.execute!( + { + path: '0', + parameters: { cssClasses: { root: 'my-root', item: 'my-item' } }, + }, + { toolCallId: 'tc1', messages: [] } + ); + + expect(result).toMatchObject({ + success: true, + applied: expect.arrayContaining(['cssClasses']), + }); + expect(callbacks.onParameterChange).toHaveBeenCalledWith( + [0], + 'cssClasses', + { root: 'my-root', item: 'my-item' } + ); + }); + it('returns empty applied when all parameters are rejected', async () => { const experience: ExperienceApiResponse = { blocks: [ diff --git a/packages/toolbar/__tests__/infinite-hits.test.tsx b/packages/toolbar/__tests__/infinite-hits.test.tsx new file mode 100644 index 0000000..e017a4b --- /dev/null +++ b/packages/toolbar/__tests__/infinite-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.infiniteHits'); +} + +describe('ais.infiniteHits field behavior', () => { + describe('switch fields (escapeHTML, showPrevious)', () => { + it('sends false when escapeHTML is 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 showPrevious is toggled on', () => { + const { onParameterChange, container } = render({ + showPrevious: false, + }); + const toggle = getSwitch(container, 'Show previous'); + + toggle.click(); + + expect(onParameterChange).toHaveBeenCalledWith('showPrevious', 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 357a73f..c63b349 100644 --- a/packages/toolbar/__tests__/toolbar.test.ts +++ b/packages/toolbar/__tests__/toolbar.test.ts @@ -168,6 +168,7 @@ describe('toolbar', () => { expect(popoverText).toContain('Search Box'); expect(popoverText).toContain('Configure'); expect(popoverText).toContain('Hits'); + expect(popoverText).toContain('Infinite Hits'); expect(popoverText).toContain('Coming Soon'); }); @@ -311,7 +312,7 @@ describe('toolbar', () => { const host = await openToolbar(); const trigger = - host.shadowRoot?.querySelector('[aria-expanded]')!; + host.shadowRoot!.querySelector('[aria-expanded]')!; // Expand trigger.click(); diff --git a/packages/toolbar/src/widget-types.tsx b/packages/toolbar/src/widget-types.tsx index 050afb6..2ce7eec 100644 --- a/packages/toolbar/src/widget-types.tsx +++ b/packages/toolbar/src/widget-types.tsx @@ -526,10 +526,69 @@ export const WIDGET_TYPES: Record = { }, 'ais.infiniteHits': { label: 'Infinite Hits', - enabled: false, + description: + 'Displays search results with a "Show more" button to load additional pages incrementally.', + enabled: true, icon: ARROW_DOWN_ICON, defaultParameters: { container: '', + escapeHTML: true, + showPrevious: false, + cssClasses: undefined, + }, + fieldOrder: [ + 'container', + 'placement', + 'escapeHTML', + 'showPrevious', + 'cssClasses', + ], + fieldOverrides: { + escapeHTML: { type: 'switch', label: 'Escape HTML' }, + showPrevious: { type: 'switch', label: 'Show previous' }, + cssClasses: { + type: 'object', + label: 'CSS classes', + disabledValue: undefined, + defaultValue: { + root: '', + emptyRoot: '', + list: '', + item: '', + loadPrevious: '', + disabledLoadPrevious: '', + loadMore: '', + disabledLoadMore: '', + bannerRoot: '', + bannerImage: '', + bannerLink: '', + }, + fields: [ + { key: 'root', label: 'Root' }, + { key: 'emptyRoot', label: 'Empty root' }, + { key: 'list', label: 'List' }, + { key: 'item', label: 'Item' }, + { key: 'loadPrevious', label: 'Load previous' }, + { key: 'disabledLoadPrevious', label: 'Disabled load previous' }, + { key: 'loadMore', label: 'Load more' }, + { key: 'disabledLoadMore', label: 'Disabled load more' }, + { key: 'bannerRoot', label: 'Banner root' }, + { key: 'bannerImage', label: 'Banner image' }, + { key: 'bannerLink', label: 'Banner link' }, + ], + }, + }, + paramLabels: { + container: 'Container', + }, + paramDescriptions: { + container: + 'CSS selector for the DOM element to render into (e.g. "#infinite-hits").', + escapeHTML: + 'When enabled, escapes HTML entities in hit string values for safety.', + showPrevious: + 'When enabled, shows a button to load previous results above the list.', + cssClasses: 'Custom CSS classes to apply to the widget elements.', }, }, 'ais.sortBy': {