Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/runtime/src/experiences/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -59,13 +60,15 @@ export type ExperienceWidget = Widget & {
'ais.autocomplete': SupportedWidget;
'ais.hits': SupportedWidget<Parameters<HitsWidget>[0]>;
'ais.infiniteHits': SupportedWidget<Parameters<InfiniteHitsWidget>[0]>;
'ais.pagination': SupportedWidget<Parameters<PaginationWidget>[0]>;
'ais.searchBox': SupportedWidget<Parameters<SearchBoxWidget>[0]>;
} & Record<
| 'ais.chat'
| 'ais.configure'
| 'ais.autocomplete'
| 'ais.hits'
| 'ais.infiniteHits'
| 'ais.pagination'
| 'ais.searchBox'
| (string & {}),
SupportedWidget
Expand Down
8 changes: 8 additions & 0 deletions packages/runtime/src/experiences/widget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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) {
Expand Down
98 changes: 97 additions & 1 deletion packages/toolbar/__tests__/ai-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});

Expand Down Expand Up @@ -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: [
Expand Down Expand Up @@ -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: [
Expand Down
210 changes: 210 additions & 0 deletions packages/toolbar/__tests__/pagination.test.tsx
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {}) {
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' })
);
});
});
});
Loading