From 94558d55e3c7deb4e4de797af395612c078b88de Mon Sep 17 00:00:00 2001 From: Sarah Dayan <5370675+sarahdayan@users.noreply.github.com> Date: Sun, 22 Feb 2026 14:37:38 +0100 Subject: [PATCH] feat: add `ais.stats` widget support Co-Authored-By: Claude Opus 4.6 --- packages/runtime/src/experiences/types.ts | 3 + packages/runtime/src/experiences/widget.tsx | 8 +++ packages/toolbar/__tests__/ai-tools.test.ts | 55 ++++++++++++++++++ packages/toolbar/__tests__/stats.test.tsx | 64 +++++++++++++++++++++ packages/toolbar/src/widget-types.tsx | 35 +++++++++++ 5 files changed, 165 insertions(+) create mode 100644 packages/toolbar/__tests__/stats.test.tsx diff --git a/packages/runtime/src/experiences/types.ts b/packages/runtime/src/experiences/types.ts index d3373a5..ba09d36 100644 --- a/packages/runtime/src/experiences/types.ts +++ b/packages/runtime/src/experiences/types.ts @@ -9,6 +9,7 @@ import type { HitsWidget } from 'instantsearch.js/es/widgets/hits/hits'; import type { InfiniteHitsWidget } from 'instantsearch.js/es/widgets/infinite-hits/infinite-hits'; import type { PaginationWidget } from 'instantsearch.js/es/widgets/pagination/pagination'; import type { SearchBoxWidget } from 'instantsearch.js/es/widgets/search-box/search-box'; +import type { StatsWidget } from 'instantsearch.js/es/widgets/stats/stats'; export type Environment = 'prod' | 'beta'; @@ -62,6 +63,7 @@ export type ExperienceWidget = Widget & { 'ais.infiniteHits': SupportedWidget[0]>; 'ais.pagination': SupportedWidget[0]>; 'ais.searchBox': SupportedWidget[0]>; + 'ais.stats': SupportedWidget[0]>; } & Record< | 'ais.chat' | 'ais.configure' @@ -70,6 +72,7 @@ export type ExperienceWidget = Widget & { | 'ais.infiniteHits' | 'ais.pagination' | 'ais.searchBox' + | 'ais.stats' | (string & {}), SupportedWidget >; diff --git a/packages/runtime/src/experiences/widget.tsx b/packages/runtime/src/experiences/widget.tsx index 3316d50..7f7c9bd 100644 --- a/packages/runtime/src/experiences/widget.tsx +++ b/packages/runtime/src/experiences/widget.tsx @@ -10,6 +10,7 @@ import infiniteHits from 'instantsearch.js/es/widgets/infinite-hits/infinite-hit import { EXPERIMENTAL_autocomplete } from 'instantsearch.js/es/widgets/autocomplete/autocomplete'; import pagination from 'instantsearch.js/es/widgets/pagination/pagination'; import searchBox from 'instantsearch.js/es/widgets/search-box/search-box'; +import stats from 'instantsearch.js/es/widgets/stats/stats'; import { renderTemplate, renderTool } from './renderer'; import type { ExperienceWidget } from './types'; @@ -175,6 +176,13 @@ export default (function experience(widgetParams: ExperienceWidgetParams) { return params; }, }, + // TODO: Add support for `templates` (text) + 'ais.stats': { + widget: stats, + 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 116050d..fad66ca 100644 --- a/packages/toolbar/__tests__/ai-tools.test.ts +++ b/packages/toolbar/__tests__/ai-tools.test.ts @@ -45,6 +45,8 @@ describe('describeWidgetTypes', () => { expect(result).toContain('Infinite Hits'); expect(result).toContain('ais.searchBox'); expect(result).toContain('Search Box'); + expect(result).toContain('ais.stats'); + expect(result).toContain('Stats'); }); it('includes widget descriptions and parameter descriptions', () => { @@ -817,6 +819,27 @@ describe('getTools', () => { ); }); + it('adds ais.stats widget', async () => { + const experience: ExperienceApiResponse = { + blocks: [], + indexName: '', + }; + const callbacks = createCallbacks(experience); + const tools = getTools(callbacks); + + const result = await tools.add_widget.execute!( + { type: 'ais.stats', container: '#stats' }, + { toolCallId: 'tc1', messages: [] } + ); + + expect(callbacks.onAddBlock).toHaveBeenCalledWith('ais.stats', undefined); + expect(result).toMatchObject({ + success: true, + type: 'ais.stats', + container: '#stats', + }); + }); + it('computes the correct index for non-empty experiences', async () => { const experience: ExperienceApiResponse = { blocks: [ @@ -1677,6 +1700,38 @@ describe('getTools', () => { { hitsPerPage: 20, filters: 'category:Books' } ); }); + + it('edits ais.stats cssClasses', async () => { + const experience: ExperienceApiResponse = { + blocks: [ + { + type: 'ais.stats', + parameters: { container: '#stats' }, + }, + ], + indexName: '', + }; + const callbacks = createCallbacks(experience); + const tools = getTools(callbacks); + + const result = await tools.edit_widget.execute!( + { + path: '0', + parameters: { cssClasses: { root: 'my-stats', text: 'my-text' } }, + }, + { toolCallId: 'tc1', messages: [] } + ); + + expect(result).toMatchObject({ + success: true, + applied: ['cssClasses'], + }); + expect(callbacks.onParameterChange).toHaveBeenCalledWith( + [0], + 'cssClasses', + { root: 'my-stats', text: 'my-text' } + ); + }); }); describe('remove_widget', () => { diff --git a/packages/toolbar/__tests__/stats.test.tsx b/packages/toolbar/__tests__/stats.test.tsx new file mode 100644 index 0000000..e0b2552 --- /dev/null +++ b/packages/toolbar/__tests__/stats.test.tsx @@ -0,0 +1,64 @@ +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.stats'); +} + +describe('ais.stats field behavior', () => { + 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: '', text: '' }) + ); + }); + + 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: '', text: '' }, + }); + const input = getInput(container, 'Root'); + + fireInput(input, 'my-root'); + + expect(onParameterChange).toHaveBeenCalledWith( + 'cssClasses', + expect.objectContaining({ root: 'my-root' }) + ); + }); + }); +}); diff --git a/packages/toolbar/src/widget-types.tsx b/packages/toolbar/src/widget-types.tsx index 7e5a5d7..f8639c2 100644 --- a/packages/toolbar/src/widget-types.tsx +++ b/packages/toolbar/src/widget-types.tsx @@ -716,6 +716,41 @@ export const WIDGET_TYPES: Record = { container: '', }, }, + 'ais.stats': { + label: 'Stats', + description: + 'Displays search result statistics such as the number of hits and processing time.', + enabled: true, + icon: TRENDING_ICON, + defaultParameters: { + container: '', + cssClasses: undefined, + }, + fieldOrder: ['container', 'placement', 'cssClasses'], + fieldOverrides: { + cssClasses: { + type: 'object', + label: 'CSS classes', + disabledValue: undefined, + defaultValue: { + root: '', + text: '', + }, + fields: [ + { key: 'root', label: 'Root' }, + { key: 'text', label: 'Text' }, + ], + }, + }, + paramLabels: { + container: 'Container', + }, + paramDescriptions: { + container: + 'CSS selector for the DOM element to render into (e.g. "#stats").', + cssClasses: 'Custom CSS classes to apply to the widget elements.', + }, + }, 'ais.frequentlyBoughtTogether': { label: 'Frequently Bought Together', enabled: false,