From a15657b963afe9184f4bc71270afbda781038cb0 Mon Sep 17 00:00:00 2001 From: Sarah Dayan <5370675+sarahdayan@users.noreply.github.com> Date: Sun, 22 Feb 2026 10:22:49 +0100 Subject: [PATCH] feat: add pagination widget support Register the pagination widget in the runtime and enable it in the toolbar with number fields, toggleable scroll-to, boolean switches, and CSS classes configuration. 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 | 98 +++++++- .../toolbar/__tests__/pagination.test.tsx | 210 ++++++++++++++++++ packages/toolbar/__tests__/toolbar.test.ts | 9 +- packages/toolbar/src/widget-types.tsx | 87 +++++++- 6 files changed, 410 insertions(+), 5 deletions(-) create mode 100644 packages/toolbar/__tests__/pagination.test.tsx diff --git a/packages/runtime/src/experiences/types.ts b/packages/runtime/src/experiences/types.ts index 4ac221c..d3373a5 100644 --- a/packages/runtime/src/experiences/types.ts +++ b/packages/runtime/src/experiences/types.ts @@ -7,6 +7,7 @@ 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 { PaginationWidget } from 'instantsearch.js/es/widgets/pagination/pagination'; import type { SearchBoxWidget } from 'instantsearch.js/es/widgets/search-box/search-box'; export type Environment = 'prod' | 'beta'; @@ -59,6 +60,7 @@ export type ExperienceWidget = Widget & { 'ais.autocomplete': SupportedWidget; 'ais.hits': SupportedWidget[0]>; 'ais.infiniteHits': SupportedWidget[0]>; + 'ais.pagination': SupportedWidget[0]>; 'ais.searchBox': SupportedWidget[0]>; } & Record< | 'ais.chat' @@ -66,6 +68,7 @@ export type ExperienceWidget = Widget & { | 'ais.autocomplete' | 'ais.hits' | 'ais.infiniteHits' + | 'ais.pagination' | 'ais.searchBox' | (string & {}), SupportedWidget diff --git a/packages/runtime/src/experiences/widget.tsx b/packages/runtime/src/experiences/widget.tsx index ba0f858..3316d50 100644 --- a/packages/runtime/src/experiences/widget.tsx +++ b/packages/runtime/src/experiences/widget.tsx @@ -8,6 +8,7 @@ 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 pagination from 'instantsearch.js/es/widgets/pagination/pagination'; import searchBox from 'instantsearch.js/es/widgets/search-box/search-box'; import { renderTemplate, renderTool } from './renderer'; @@ -104,6 +105,13 @@ export default (function experience(widgetParams: ExperienceWidgetParams) { return { ...searchParameters }; }, }, + // TODO: Add support for `templates` (first, previous, page, next, last) + 'ais.pagination': { + widget: pagination, + async transformParams(parameters) { + return parameters; + }, + }, 'ais.autocomplete': { widget: EXPERIMENTAL_autocomplete, async transformParams(params) { diff --git a/packages/toolbar/__tests__/ai-tools.test.ts b/packages/toolbar/__tests__/ai-tools.test.ts index e071a44..116050d 100644 --- a/packages/toolbar/__tests__/ai-tools.test.ts +++ b/packages/toolbar/__tests__/ai-tools.test.ts @@ -72,9 +72,17 @@ describe('describeWidgetTypes', () => { expect(result).toContain('[index-independent]'); }); + it('includes pagination widget type', () => { + const result = describeWidgetTypes(); + expect(result).toContain('ais.pagination'); + expect(result).toContain('Pagination'); + expect(result).toContain('paginated search results'); + expect(result).toContain('showFirst'); + }); + it('excludes disabled widget types', () => { const result = describeWidgetTypes(); - expect(result).not.toContain('ais.pagination'); + expect(result).not.toContain('ais.refinementList'); }); }); @@ -765,6 +773,50 @@ describe('getTools', () => { ); }); + it('adds a pagination widget with boolean parameters', async () => { + const experience: ExperienceApiResponse = { + blocks: [], + indexName: '', + }; + const callbacks = createCallbacks(experience); + const tools = getTools(callbacks); + + const result = await tools.add_widget.execute!( + { + type: 'ais.pagination', + container: '#pagination', + parameters: { showFirst: false, padding: 5 }, + }, + { toolCallId: 'tc1', messages: [] } + ); + + expect(result).toMatchObject({ + success: true, + type: 'ais.pagination', + applied: expect.arrayContaining([ + 'placement', + 'container', + 'showFirst', + 'padding', + ]), + rejected: [], + }); + expect(callbacks.onAddBlock).toHaveBeenCalledWith( + 'ais.pagination', + undefined + ); + expect(callbacks.onParameterChange).toHaveBeenCalledWith( + [0], + 'showFirst', + false + ); + expect(callbacks.onParameterChange).toHaveBeenCalledWith( + [0], + 'padding', + 5 + ); + }); + it('computes the correct index for non-empty experiences', async () => { const experience: ExperienceApiResponse = { blocks: [ @@ -1363,6 +1415,50 @@ describe('getTools', () => { ); }); + it('applies boolean and cssClasses changes on a pagination widget', async () => { + const experience: ExperienceApiResponse = { + blocks: [ + { + type: 'ais.pagination', + parameters: { + container: '#pagination', + showFirst: true, + cssClasses: { root: '' }, + }, + }, + ], + indexName: '', + }; + const callbacks = createCallbacks(experience); + const tools = getTools(callbacks); + + const result = await tools.edit_widget.execute!( + { + path: '0', + parameters: { + showFirst: false, + cssClasses: { root: 'my-root' }, + }, + }, + { toolCallId: 'tc1', messages: [] } + ); + + expect(result).toMatchObject({ + success: true, + applied: expect.arrayContaining(['showFirst', 'cssClasses']), + }); + expect(callbacks.onParameterChange).toHaveBeenCalledWith( + [0], + 'showFirst', + false + ); + expect(callbacks.onParameterChange).toHaveBeenCalledWith( + [0], + 'cssClasses', + { root: 'my-root' } + ); + }); + it('returns empty applied when all parameters are rejected', async () => { const experience: ExperienceApiResponse = { blocks: [ diff --git a/packages/toolbar/__tests__/pagination.test.tsx b/packages/toolbar/__tests__/pagination.test.tsx new file mode 100644 index 0000000..0f30284 --- /dev/null +++ b/packages/toolbar/__tests__/pagination.test.tsx @@ -0,0 +1,210 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { + fireInput, + getInput, + getSwitch, + getToggleableInput, + renderEditor, +} from './widget-test-utils'; + +afterEach(() => { + document.body.innerHTML = ''; +}); + +function render(params: Record = {}) { + return renderEditor(params, 'ais.pagination'); +} + +describe('ais.pagination field behavior', () => { + describe('number fields (totalPages, padding)', () => { + it('sends undefined when the field is empty (unset)', () => { + const { onParameterChange, container } = render(); + const input = getInput(container, 'Total Pages'); + + expect(input).not.toBeNull(); + expect(input.type).toBe('number'); + expect(input.value).toBe(''); + + fireInput(input, ''); + + expect(onParameterChange).toHaveBeenCalledWith('totalPages', undefined); + }); + + it('sends a number when a value is entered', () => { + const { onParameterChange, container } = render(); + const input = getInput(container, 'Total Pages'); + + fireInput(input, '10'); + + expect(onParameterChange).toHaveBeenCalledWith('totalPages', 10); + }); + + it('sends undefined when cleared after having a value', () => { + const { onParameterChange, container } = render({ + totalPages: 10, + }); + const input = getInput(container, 'Total Pages'); + + expect(input.value).toBe('10'); + + fireInput(input, ''); + + expect(onParameterChange).toHaveBeenCalledWith('totalPages', undefined); + }); + + it('displays the placeholder when unset', () => { + const { container } = render(); + const input = getInput(container, 'Padding'); + + expect(input.placeholder).toBe('3'); + expect(input.value).toBe(''); + }); + + it('renders even when the parameter is absent from the API response', () => { + // Simulates a saved pagination widget where totalPages was never set + const { container } = render({ showFirst: true }); + const input = getInput(container, 'Total Pages'); + + expect(input).not.toBeNull(); + }); + }); + + describe('toggleable text field with picker (scrollTo)', () => { + it('sends false when toggled off', () => { + const { onParameterChange, container } = render(); + const toggle = getSwitch(container, 'Scroll to'); + + toggle.click(); + + expect(onParameterChange).toHaveBeenCalledWith('scrollTo', false); + }); + + it('sends undefined when toggled on (uses library default)', () => { + const { onParameterChange, container } = render({ + scrollTo: false, + }); + const toggle = getSwitch(container, 'Scroll to'); + + toggle.click(); + + expect(onParameterChange).toHaveBeenCalledWith('scrollTo', undefined); + }); + + it('sends a string when a value is entered', () => { + const { onParameterChange, container } = render(); + const input = getToggleableInput(container, 'Scroll to'); + + fireInput(input, '#results'); + + expect(onParameterChange).toHaveBeenCalledWith('scrollTo', '#results'); + }); + + it('sends undefined when the text field is cleared', () => { + const { onParameterChange, container } = render({ + scrollTo: '#results', + }); + const input = getToggleableInput(container, 'Scroll to'); + + expect(input.value).toBe('#results'); + + fireInput(input, ''); + + expect(onParameterChange).toHaveBeenCalledWith('scrollTo', undefined); + }); + + it('displays the placeholder when enabled with no value', () => { + const { container } = render(); + const input = getToggleableInput(container, 'Scroll to'); + + expect(input.placeholder).toBe('body'); + expect(input.value).toBe(''); + }); + + it('collapses the text field when toggled off', () => { + const { container } = render({ scrollTo: false }); + const collapsible = container.querySelector( + '[data-slot="collapsible-content"][data-state="closed"]' + ); + + expect(collapsible).not.toBeNull(); + }); + + it('renders the element picker button when enabled', () => { + const { container } = render(); + const button = container.querySelector('button[title="Pick an element"]'); + + expect(button).not.toBeNull(); + }); + }); + + describe('switch fields (showFirst, showLast, etc.)', () => { + it('sends false when toggled off', () => { + const { onParameterChange, container } = render({ + showFirst: true, + }); + const toggle = getSwitch(container, 'Show first page'); + + toggle.click(); + + expect(onParameterChange).toHaveBeenCalledWith('showFirst', false); + }); + + it('sends true when toggled on', () => { + const { onParameterChange, container } = render({ + showFirst: false, + }); + const toggle = getSwitch(container, 'Show first page'); + + toggle.click(); + + expect(onParameterChange).toHaveBeenCalledWith('showFirst', 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: '', link: '' }) + ); + }); + + 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 c63b349..0671eed 100644 --- a/packages/toolbar/__tests__/toolbar.test.ts +++ b/packages/toolbar/__tests__/toolbar.test.ts @@ -167,6 +167,7 @@ describe('toolbar', () => { expect(popoverText).toContain('Chat'); expect(popoverText).toContain('Search Box'); expect(popoverText).toContain('Configure'); + expect(popoverText).toContain('Pagination'); expect(popoverText).toContain('Hits'); expect(popoverText).toContain('Infinite Hits'); expect(popoverText).toContain('Coming Soon'); @@ -221,18 +222,20 @@ describe('toolbar', () => { }); // Disabled items are rendered as
elements, not