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 @@ -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';
Expand Down Expand Up @@ -57,12 +58,14 @@ export type ExperienceWidget = Widget & {
'ais.configure': SupportedWidget<Parameters<ConfigureWidget>[0]>;
'ais.autocomplete': SupportedWidget;
'ais.hits': SupportedWidget<Parameters<HitsWidget>[0]>;
'ais.infiniteHits': SupportedWidget<Parameters<InfiniteHitsWidget>[0]>;
'ais.searchBox': SupportedWidget<Parameters<SearchBoxWidget>[0]>;
} & Record<
| 'ais.chat'
| 'ais.configure'
| 'ais.autocomplete'
| 'ais.hits'
| 'ais.infiniteHits'
| 'ais.searchBox'
| (string & {}),
SupportedWidget
Expand Down
10 changes: 10 additions & 0 deletions packages/runtime/src/experiences/widget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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': {
Expand Down
187 changes: 187 additions & 0 deletions packages/toolbar/__tests__/ai-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
Expand Down Expand Up @@ -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: [
{
Expand Down Expand Up @@ -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: [
Expand Down
88 changes: 88 additions & 0 deletions packages/toolbar/__tests__/infinite-hits.test.tsx
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {}) {
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' })
);
});
});
});
3 changes: 2 additions & 1 deletion packages/toolbar/__tests__/toolbar.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});

Expand Down Expand Up @@ -311,7 +312,7 @@ describe('toolbar', () => {
const host = await openToolbar();

const trigger =
host.shadowRoot?.querySelector<HTMLButtonElement>('[aria-expanded]')!;
host.shadowRoot!.querySelector<HTMLButtonElement>('[aria-expanded]')!;

// Expand
trigger.click();
Expand Down
Loading