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
19 changes: 12 additions & 7 deletions packages/runtime/src/experiences/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,16 +126,21 @@ export function createExperienceMiddleware(
}

const { placement, ...widgetParams } = parameters;
const resolved = resolveContainer(
widgetParams.container,
placement as Placement | undefined
);
const isHeadless = supportedWidget.headless;
const resolved = isHeadless
? null
: resolveContainer(
widgetParams.container,
placement as Placement | undefined
);

if (!resolved) {
if (!isHeadless && !resolved) {
return;
}

cleanups.push(resolved.cleanup);
if (resolved) {
cleanups.push(resolved.cleanup);
}

const newWidget = supportedWidget.widget;
supportedWidget
Expand All @@ -144,7 +149,7 @@ export function createExperienceMiddleware(
if (newWidget) {
const params = {
...(transformedParams as Record<string, unknown>),
container: resolved.container,
...(resolved ? { container: resolved.container } : {}),
};
const widgets = newWidget(params);
parent.addWidgets(
Expand Down
8 changes: 7 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 { ConfigureWidget } from 'instantsearch.js/es/widgets/configure/configure';

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

Expand All @@ -30,6 +31,7 @@ type SupportedWidget<
TApiParameters = ExperienceApiBlockParameters,
> = {
widget: (...args: any[]) => Widget | Array<IndexWidget | Widget>;
headless?: boolean;
transformParams: (
params: TApiParameters,
options: {
Expand All @@ -43,6 +45,10 @@ export type ExperienceWidget = Widget & {
$$widgetParams: ExperienceWidgetParams;
$$supportedWidgets: {
'ais.chat': SupportedWidget<Parameters<ChatWidget>[0]>;
'ais.configure': SupportedWidget<Parameters<ConfigureWidget>[0]>;
'ais.autocomplete': SupportedWidget;
} & Record<'ais.chat' | 'ais.autocomplete' | (string & {}), SupportedWidget>;
} & Record<
'ais.chat' | 'ais.configure' | 'ais.autocomplete' | (string & {}),
SupportedWidget
>;
};
12 changes: 12 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 configure from 'instantsearch.js/es/widgets/configure/configure';
import { EXPERIMENTAL_autocomplete } from 'instantsearch.js/es/widgets/autocomplete/autocomplete';

import { renderTemplate, renderTool } from './renderer';
Expand Down Expand Up @@ -89,6 +90,17 @@ export default (function experience(widgetParams: ExperienceWidgetParams) {
});
},
},
'ais.configure': {
widget: configure,
headless: true,
async transformParams(params) {
const { searchParameters } = params as typeof params & {
searchParameters?: Record<string, unknown>;
};

return { ...searchParameters };
},
},
'ais.autocomplete': {
widget: EXPERIMENTAL_autocomplete,
async transformParams(params) {
Expand Down
75 changes: 75 additions & 0 deletions 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.configure');
expect(result).toContain('Configure');
});

it('includes widget descriptions and parameter descriptions', () => {
Expand Down Expand Up @@ -452,6 +454,45 @@ describe('getTools', () => {

expect(result).toMatchObject({ index: 1 });
});

it('adds configure widget with body placement and no container', async () => {
const experience: ExperienceApiResponse = { blocks: [] };
const callbacks = createCallbacks(experience);
const tools = getTools(callbacks);

const result = await tools.add_widget.execute!(
{
type: 'ais.configure',
parameters: { searchParameters: { hitsPerPage: 20 } },
},
{ toolCallId: 'tc1', messages: [] }
);

expect(result).toMatchObject({
success: true,
index: 0,
type: 'ais.configure',
placement: 'body',
applied: expect.arrayContaining(['placement', 'searchParameters']),
rejected: [],
});
expect(callbacks.onAddBlock).toHaveBeenCalledWith('ais.configure');
expect(callbacks.onParameterChange).toHaveBeenCalledWith(
0,
'placement',
'body'
);
expect(callbacks.onParameterChange).toHaveBeenCalledWith(
0,
'searchParameters',
{ hitsPerPage: 20 }
);
expect(callbacks.onParameterChange).not.toHaveBeenCalledWith(
0,
'container',
expect.anything()
);
});
});

describe('edit_widget', () => {
Expand Down Expand Up @@ -764,6 +805,40 @@ describe('getTools', () => {
error: expect.stringContaining('indices 0–1'),
});
});

it('applies a json parameter (searchParameters) on configure', async () => {
const experience: ExperienceApiResponse = {
blocks: [
{
type: 'ais.configure',
parameters: { searchParameters: { hitsPerPage: 10 } },
},
],
};
const callbacks = createCallbacks(experience);
const tools = getTools(callbacks);

const result = await tools.edit_widget.execute!(
{
index: 0,
parameters: {
searchParameters: { hitsPerPage: 20, filters: 'category:Books' },
},
},
{ toolCallId: 'tc1', messages: [] }
);

expect(result).toMatchObject({
success: true,
applied: ['searchParameters'],
rejected: [],
});
expect(callbacks.onParameterChange).toHaveBeenCalledWith(
0,
'searchParameters',
{ hitsPerPage: 20, filters: 'category:Books' }
);
});
});

describe('remove_widget', () => {
Expand Down
102 changes: 102 additions & 0 deletions packages/toolbar/__tests__/configure.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { afterEach, describe, expect, it } from 'vitest';

import { renderEditor } from './widget-test-utils';

afterEach(() => {
document.body.innerHTML = '';
});

function render(params: Record<string, unknown> = {}) {
return renderEditor(params, 'ais.configure');
}

function getTextarea(
container: HTMLElement,
label: string
): HTMLTextAreaElement {
const labels = Array.from(container.querySelectorAll('label'));
const target = labels.find((el) => {
return el.textContent?.trim() === label;
});

if (!target) {
throw new Error(`No label found with text "${label}"`);
}

const id = target.getAttribute('for');
const textarea = container.querySelector(`#${id}`);

if (!textarea) {
throw new Error(`No textarea found for label "${label}" (for="${id}")`);
}

return textarea as HTMLTextAreaElement;
}

function fireTextareaInput(textarea: HTMLTextAreaElement, value: string) {
Object.getOwnPropertyDescriptor(
HTMLTextAreaElement.prototype,
'value'
)!.set!.call(textarea, value);
textarea.dispatchEvent(new Event('input', { bubbles: true }));
}

describe('ais.configure field behavior', () => {
describe('json field (searchParameters)', () => {
it('renders the textarea with serialized JSON', () => {
const { container } = render({
searchParameters: { hitsPerPage: 20 },
});
const textarea = getTextarea(container, 'Search parameters');

expect(textarea.value).toContain('"hitsPerPage": 20');
});

it('sends the parsed object when valid JSON is entered', () => {
const { onParameterChange, container } = render({
searchParameters: {},
});
const textarea = getTextarea(container, 'Search parameters');

fireTextareaInput(textarea, '{"hitsPerPage": 10}');

expect(onParameterChange).toHaveBeenCalledWith('searchParameters', {
hitsPerPage: 10,
});
});

it('does not call onChange when invalid JSON is entered', () => {
const { onParameterChange, container } = render({
searchParameters: {},
});
const textarea = getTextarea(container, 'Search parameters');

fireTextareaInput(textarea, '{invalid}');

expect(onParameterChange).not.toHaveBeenCalled();
});

it('shows an error message for invalid JSON', async () => {
const { container } = render({ searchParameters: {} });
const textarea = getTextarea(container, 'Search parameters');

fireTextareaInput(textarea, '{invalid}');

// Wait for Preact to flush the state update
await new Promise((resolve) => {
return setTimeout(resolve, 10);
});

const errorText = container.textContent ?? '';

expect(errorText).toContain('Invalid JSON');
});

it('renders an empty object when searchParameters is absent', () => {
const { container } = render();
const textarea = getTextarea(container, 'Search parameters');

expect(textarea.value).toBe('{}');
});
});
});
69 changes: 69 additions & 0 deletions packages/toolbar/__tests__/parse-json-object.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { describe, expect, it } from 'vitest';

import { parseJsonObject } from '../src/utils/parse-json-object';

describe('parseJsonObject', () => {
it('returns the parsed object for a valid JSON object string', () => {
expect(parseJsonObject('{"hitsPerPage": 20}')).toEqual({
success: true,
value: { hitsPerPage: 20 },
});
});

it('returns the parsed object for a deeply nested object', () => {
const input = JSON.stringify({
filters: { category: { nested: { deep: true } } },
hitsPerPage: 10,
});

expect(parseJsonObject(input)).toEqual({
success: true,
value: {
filters: { category: { nested: { deep: true } } },
hitsPerPage: 10,
},
});
});

it('returns an error for a JSON array', () => {
expect(parseJsonObject('[1, 2, 3]')).toEqual({
success: false,
error: 'Must be a JSON object',
});
});

it('returns an error for a JSON string primitive', () => {
expect(parseJsonObject('"hello"')).toEqual({
success: false,
error: 'Must be a JSON object',
});
});

it('returns an error for a JSON number primitive', () => {
expect(parseJsonObject('42')).toEqual({
success: false,
error: 'Must be a JSON object',
});
});

it('returns an error for JSON null', () => {
expect(parseJsonObject('null')).toEqual({
success: false,
error: 'Must be a JSON object',
});
});

it('returns an error for invalid JSON', () => {
expect(parseJsonObject('{not valid json}')).toEqual({
success: false,
error: 'Invalid JSON',
});
});

it('returns an error for an empty string', () => {
expect(parseJsonObject('')).toEqual({
success: false,
error: 'Invalid JSON',
});
});
});
1 change: 1 addition & 0 deletions packages/toolbar/__tests__/toolbar.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ describe('toolbar', () => {
const popoverText = host.shadowRoot?.innerHTML ?? '';
expect(popoverText).toContain('Autocomplete');
expect(popoverText).toContain('Chat');
expect(popoverText).toContain('Configure');
expect(popoverText).toContain('Hits');
expect(popoverText).toContain('Coming Soon');
});
Expand Down
16 changes: 16 additions & 0 deletions packages/toolbar/src/components/block-editor.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { ExperienceApiBlockParameters, Placement } from '../types';
import { WIDGET_TYPES } from '../widget-types';
import { CssVariablesEditor } from './fields/css-variables-editor';
import { JsonField } from './fields/json-field';
import { NumberField } from './fields/number-field';
import { ObjectField } from './fields/object-field';
import { PlacementField } from './fields/placement-field';
Expand Down Expand Up @@ -163,6 +164,21 @@ export function BlockEditor({
onPickElement={onPickElement}
/>
);
case 'json':
return (
<JsonField
key={key}
label={override.label}
value={
typeof value === 'object' && value !== null
? (value as Record<string, unknown>)
: {}
}
onChange={(newValue) => {
return onParameterChange(key, newValue);
}}
/>
);
case 'object': {
const enabled = typeof value === 'object' && value !== null;
const objectValue = enabled
Expand Down
Loading