Skip to content
Open
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
7 changes: 6 additions & 1 deletion packages/runtime/src/experiences/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type {
Widget,
} from 'instantsearch.js/es/types';
import type { ChatWidget } from 'instantsearch.js/es/widgets/chat/chat';
import type { HitsWidget } from 'instantsearch.js/es/widgets/hits/hits';

export type Environment = 'prod' | 'beta';

Expand Down Expand Up @@ -44,5 +45,9 @@ export type ExperienceWidget = Widget & {
$$supportedWidgets: {
'ais.chat': SupportedWidget<Parameters<ChatWidget>[0]>;
'ais.autocomplete': SupportedWidget;
} & Record<'ais.chat' | 'ais.autocomplete' | (string & {}), SupportedWidget>;
'ais.hits': SupportedWidget<Parameters<HitsWidget>[0]>;
} & Record<
'ais.chat' | 'ais.autocomplete' | 'ais.hits' | (string & {}),
SupportedWidget
>;
};
9 changes: 9 additions & 0 deletions packages/runtime/src/experiences/widget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
} from 'instantsearch.js/es/lib/utils';
import { getExperience } from './get-experience';
import chat from 'instantsearch.js/es/widgets/chat/chat';
import hits from 'instantsearch.js/es/widgets/hits/hits';
import { EXPERIMENTAL_autocomplete } from 'instantsearch.js/es/widgets/autocomplete/autocomplete';

import { renderTemplate, renderTool } from './renderer';
Expand Down Expand Up @@ -127,6 +128,14 @@ export default (function experience(widgetParams: ExperienceWidgetParams) {
});
},
},
// TODO: Add support for `templates` (item, empty, banner)
// TODO: Add support for `transformItems` (bucket 3 function)
'ais.hits': {
widget: hits,
async transformParams(parameters) {
return parameters;
},
},
},
render: () => {},
dispose: () => {},
Expand Down
102 changes: 101 additions & 1 deletion packages/toolbar/__tests__/ai-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ describe('describeWidgetTypes', () => {
expect(result).toContain('Autocomplete');
expect(result).toContain('ais.chat');
expect(result).toContain('Chat');
expect(result).toContain('ais.hits');
expect(result).toContain('Hits');
});

it('includes widget descriptions and parameter descriptions', () => {
Expand All @@ -38,6 +40,9 @@ describe('describeWidgetTypes', () => {
expect(result).toContain('Parameters:');
expect(result).toContain('showRecent');
expect(result).toContain('recent searches');
expect(result).toContain('search results');
expect(result).toContain('escapeHTML');
expect(result).toContain('XSS');
});

it('includes default placement per widget type', () => {
Expand All @@ -46,11 +51,11 @@ describe('describeWidgetTypes', () => {
'ais.autocomplete ("Autocomplete", default placement: inside)'
);
expect(result).toContain('ais.chat ("Chat", default placement: body)');
expect(result).toContain('ais.hits ("Hits", default placement: inside)');
});

it('excludes disabled widget types', () => {
const result = describeWidgetTypes();
expect(result).not.toContain('ais.hits');
expect(result).not.toContain('ais.searchBox');
expect(result).not.toContain('ais.pagination');
});
Expand Down Expand Up @@ -433,6 +438,38 @@ describe('getTools', () => {
expect(callbacks.onAddBlock).toHaveBeenCalledWith('ais.autocomplete');
});

it('adds hits with default inside placement and container', async () => {
const experience: ExperienceApiResponse = { blocks: [] };
const callbacks = createCallbacks(experience);
const tools = getTools(callbacks);

const result = await tools.add_widget.execute!(
{ type: 'ais.hits', container: '#hits' },
{ toolCallId: 'tc1', messages: [] }
);

expect(callbacks.onAddBlock).toHaveBeenCalledWith('ais.hits');
expect(callbacks.onParameterChange).toHaveBeenCalledWith(
0,
'placement',
'inside'
);
expect(callbacks.onParameterChange).toHaveBeenCalledWith(
0,
'container',
'#hits'
);
expect(result).toMatchObject({
success: true,
index: 0,
type: 'ais.hits',
placement: 'inside',
container: '#hits',
applied: ['placement', 'container'],
rejected: [],
});
});

it('computes the correct index for non-empty experiences', async () => {
const experience: ExperienceApiResponse = {
blocks: [
Expand Down Expand Up @@ -738,6 +775,69 @@ describe('getTools', () => {
expect(callbacks.onParameterChange).not.toHaveBeenCalled();
});

it('applies a boolean parameter with switch override on hits', async () => {
const experience: ExperienceApiResponse = {
blocks: [
{
type: 'ais.hits',
parameters: { container: '#hits', escapeHTML: true },
},
],
};
const callbacks = createCallbacks(experience);
const tools = getTools(callbacks);

const result = await tools.edit_widget.execute!(
{ index: 0, parameters: { escapeHTML: false } },
{ toolCallId: 'tc1', messages: [] }
);

expect(result).toMatchObject({
success: true,
applied: ['escapeHTML'],
rejected: [],
});
expect(callbacks.onParameterChange).toHaveBeenCalledWith(
0,
'escapeHTML',
false
);
});

it('applies an object parameter (cssClasses) on hits', async () => {
const experience: ExperienceApiResponse = {
blocks: [
{
type: 'ais.hits',
parameters: { container: '#hits' },
},
],
};
const callbacks = createCallbacks(experience);
const tools = getTools(callbacks);

const result = await tools.edit_widget.execute!(
{
index: 0,
parameters: {
cssClasses: { root: 'my-root', item: 'my-item' },
},
},
{ toolCallId: 'tc1', messages: [] }
);

expect(result).toMatchObject({
success: true,
applied: ['cssClasses'],
rejected: [],
});
expect(callbacks.onParameterChange).toHaveBeenCalledWith(
0,
'cssClasses',
{ root: 'my-root', item: 'my-item' }
);
});

it('includes index range in bounds error message', async () => {
const experience: ExperienceApiResponse = {
blocks: [
Expand Down
88 changes: 88 additions & 0 deletions packages/toolbar/__tests__/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.hits');
}

describe('ais.hits field behavior', () => {
describe('switch field (escapeHTML)', () => {
it('sends false when 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 toggled on', () => {
const { onParameterChange, container } = render({
escapeHTML: false,
});
const toggle = getSwitch(container, 'Escape HTML');

toggle.click();

expect(onParameterChange).toHaveBeenCalledWith('escapeHTML', 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' })
);
});
});
});
10 changes: 5 additions & 5 deletions packages/toolbar/__tests__/toolbar.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,18 +218,18 @@ describe('toolbar', () => {
});

// Disabled items are rendered as <div> elements, not <button>
// Check that "Hits" is not a button
// Check that "Search Box" is not a button
const buttons = Array.from(
host.shadowRoot?.querySelectorAll('button') ?? []
);
const recommendationsButton = buttons.find((btn) => {
return btn.textContent?.includes('Hits') && btn !== addButton;
const disabledButton = buttons.find((btn) => {
return btn.textContent?.includes('Search Box') && btn !== addButton;
});
expect(recommendationsButton).toBeUndefined();
expect(disabledButton).toBeUndefined();

// Verify the text is still rendered (as a div)
const popoverText = host.shadowRoot?.innerHTML ?? '';
expect(popoverText).toContain('Hits');
expect(popoverText).toContain('Search Box');
});
});

Expand Down
44 changes: 43 additions & 1 deletion packages/toolbar/src/widget-types.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -293,10 +293,52 @@ export const WIDGET_TYPES: Record<string, WidgetTypeConfig> = {
},
'ais.hits': {
label: 'Hits',
enabled: false,
description:
'Displays the list of search results (hits) matching the current query.',
enabled: true,
icon: GRID_ICON,
defaultParameters: {
container: '',
escapeHTML: true,
cssClasses: undefined,
},
fieldOrder: ['container', 'placement', 'escapeHTML', 'cssClasses'],
fieldOverrides: {
escapeHTML: { type: 'switch', label: 'Escape HTML' },
cssClasses: {
type: 'object',
label: 'CSS classes',
disabledValue: undefined,
defaultValue: {
root: '',
emptyRoot: '',
list: '',
item: '',
bannerRoot: '',
bannerImage: '',
bannerLink: '',
},
fields: [
{ key: 'root', label: 'Root' },
{ key: 'emptyRoot', label: 'Empty root' },
{ key: 'list', label: 'List' },
{ key: 'item', label: 'Item' },
{ 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. "#hits").',
escapeHTML:
'When enabled, escapes HTML tags in hit string values to prevent XSS.',
cssClasses:
'Custom CSS classes to apply to specific parts of the widget.',
},
},
'ais.searchBox': {
Expand Down