From ca5480e3538e391d9406a9ad105ec3a228cc0184 Mon Sep 17 00:00:00 2001 From: Sarah Dayan <5370675+sarahdayan@users.noreply.github.com> Date: Sat, 21 Feb 2026 09:21:28 +0100 Subject: [PATCH 1/5] feat: add `ais.configure` widget support Add the configure widget to the runtime and toolbar. This headless widget sets default Algolia search parameters without rendering any UI. - Register configure in runtime with searchParameters flattening - Add headless flag to SupportedWidget type and use it in middleware - Add JSON field component for editing search parameters in toolbar - Enable configure in toolbar widget types Co-Authored-By: Claude Opus 4.6 --- .../runtime/src/experiences/middleware.ts | 19 +++--- packages/runtime/src/experiences/types.ts | 8 ++- packages/runtime/src/experiences/widget.tsx | 12 ++++ .../toolbar/src/components/block-editor.tsx | 16 +++++ .../src/components/fields/json-field.tsx | 60 +++++++++++++++++++ packages/toolbar/src/widget-types.tsx | 37 +++++++++++- 6 files changed, 143 insertions(+), 9 deletions(-) create mode 100644 packages/toolbar/src/components/fields/json-field.tsx 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/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..c1cf928 --- /dev/null +++ b/packages/toolbar/src/components/fields/json-field.tsx @@ -0,0 +1,60 @@ +import { useId, useState } from 'preact/hooks'; + +import { cn } from '../../lib/utils'; +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); + + try { + const parsed = JSON.parse(newText); + + if ( + typeof parsed !== 'object' || + parsed === null || + Array.isArray(parsed) + ) { + setError('Must be a JSON object'); + return; + } + setError(null); + onChange(parsed); + } catch { + setError('Invalid JSON'); + } + } + + return ( +
+ +