diff --git a/packages/runtime/src/experiences/middleware.ts b/packages/runtime/src/experiences/middleware.ts index 4b28a50..5713782 100644 --- a/packages/runtime/src/experiences/middleware.ts +++ b/packages/runtime/src/experiences/middleware.ts @@ -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 @@ -144,7 +149,7 @@ export function createExperienceMiddleware( if (newWidget) { const params = { ...(transformedParams as Record), - container: resolved.container, + ...(resolved ? { container: resolved.container } : {}), }; const widgets = newWidget(params); parent.addWidgets( diff --git a/packages/runtime/src/experiences/types.ts b/packages/runtime/src/experiences/types.ts index 5652165..bdc43d9 100644 --- a/packages/runtime/src/experiences/types.ts +++ b/packages/runtime/src/experiences/types.ts @@ -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'; @@ -30,6 +31,7 @@ type SupportedWidget< TApiParameters = ExperienceApiBlockParameters, > = { widget: (...args: any[]) => Widget | Array; + headless?: boolean; transformParams: ( params: TApiParameters, options: { @@ -43,6 +45,10 @@ export type ExperienceWidget = Widget & { $$widgetParams: ExperienceWidgetParams; $$supportedWidgets: { 'ais.chat': SupportedWidget[0]>; + 'ais.configure': SupportedWidget[0]>; 'ais.autocomplete': SupportedWidget; - } & Record<'ais.chat' | 'ais.autocomplete' | (string & {}), SupportedWidget>; + } & Record< + 'ais.chat' | 'ais.configure' | 'ais.autocomplete' | (string & {}), + SupportedWidget + >; }; diff --git a/packages/runtime/src/experiences/widget.tsx b/packages/runtime/src/experiences/widget.tsx index ecae85b..449e779 100644 --- a/packages/runtime/src/experiences/widget.tsx +++ b/packages/runtime/src/experiences/widget.tsx @@ -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'; @@ -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; + }; + + return { ...searchParameters }; + }, + }, '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 2006235..d085440 100644 --- a/packages/toolbar/__tests__/ai-tools.test.ts +++ b/packages/toolbar/__tests__/ai-tools.test.ts @@ -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', () => { @@ -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', () => { @@ -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', () => { diff --git a/packages/toolbar/__tests__/configure.test.tsx b/packages/toolbar/__tests__/configure.test.tsx new file mode 100644 index 0000000..685ab77 --- /dev/null +++ b/packages/toolbar/__tests__/configure.test.tsx @@ -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 = {}) { + 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('{}'); + }); + }); +}); diff --git a/packages/toolbar/__tests__/parse-json-object.test.ts b/packages/toolbar/__tests__/parse-json-object.test.ts new file mode 100644 index 0000000..2fa6d1a --- /dev/null +++ b/packages/toolbar/__tests__/parse-json-object.test.ts @@ -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', + }); + }); +}); diff --git a/packages/toolbar/__tests__/toolbar.test.ts b/packages/toolbar/__tests__/toolbar.test.ts index c7b8fdd..a24aee0 100644 --- a/packages/toolbar/__tests__/toolbar.test.ts +++ b/packages/toolbar/__tests__/toolbar.test.ts @@ -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'); }); diff --git a/packages/toolbar/src/components/block-editor.tsx b/packages/toolbar/src/components/block-editor.tsx index e941d7a..a48c208 100644 --- a/packages/toolbar/src/components/block-editor.tsx +++ b/packages/toolbar/src/components/block-editor.tsx @@ -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'; @@ -163,6 +164,21 @@ export function BlockEditor({ onPickElement={onPickElement} /> ); + case 'json': + return ( + ) + : {} + } + onChange={(newValue) => { + return onParameterChange(key, newValue); + }} + /> + ); case 'object': { const enabled = typeof value === 'object' && value !== null; const objectValue = enabled diff --git a/packages/toolbar/src/components/fields/json-field.tsx b/packages/toolbar/src/components/fields/json-field.tsx new file mode 100644 index 0000000..8928c2d --- /dev/null +++ b/packages/toolbar/src/components/fields/json-field.tsx @@ -0,0 +1,54 @@ +import { useId, useState } from 'preact/hooks'; + +import { cn } from '../../lib/utils'; +import { parseJsonObject } from '../../utils/parse-json-object'; +import { Label } from '../ui/label'; + +type JsonFieldProps = { + label: string; + value: Record; + onChange: (value: Record) => void; +}; + +export function JsonField({ label, value, onChange }: JsonFieldProps) { + const id = useId(); + const [text, setText] = useState(() => { + return JSON.stringify(value, null, 2); + }); + const [error, setError] = useState(null); + + function onInput(newText: string) { + setText(newText); + + const result = parseJsonObject(newText); + + if (!result.success) { + setError(result.error); + return; + } + + setError(null); + onChange(result.value); + } + + return ( +
+ +