diff --git a/packages/runtime/src/experiences/types.ts b/packages/runtime/src/experiences/types.ts index 5652165..75731d3 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 { ClearRefinementsWidget } from 'instantsearch.js/es/widgets/clear-refinements/clear-refinements'; export type Environment = 'prod' | 'beta'; @@ -44,5 +45,11 @@ export type ExperienceWidget = Widget & { $$supportedWidgets: { 'ais.chat': SupportedWidget[0]>; 'ais.autocomplete': SupportedWidget; - } & Record<'ais.chat' | 'ais.autocomplete' | (string & {}), SupportedWidget>; + 'ais.clearRefinements': SupportedWidget< + Parameters[0] + >; + } & Record< + 'ais.chat' | 'ais.autocomplete' | 'ais.clearRefinements' | (string & {}), + SupportedWidget + >; }; diff --git a/packages/runtime/src/experiences/widget.tsx b/packages/runtime/src/experiences/widget.tsx index ecae85b..46b5c13 100644 --- a/packages/runtime/src/experiences/widget.tsx +++ b/packages/runtime/src/experiences/widget.tsx @@ -5,6 +5,7 @@ import { import { getExperience } from './get-experience'; import chat from 'instantsearch.js/es/widgets/chat/chat'; import { EXPERIMENTAL_autocomplete } from 'instantsearch.js/es/widgets/autocomplete/autocomplete'; +import clearRefinements from 'instantsearch.js/es/widgets/clear-refinements/clear-refinements'; import { renderTemplate, renderTool } from './renderer'; import type { ExperienceWidget } from './types'; @@ -127,6 +128,14 @@ export default (function experience(widgetParams: ExperienceWidgetParams) { }); }, }, + // TODO: Add support for `templates` (resetLabel) + // TODO: Add support for `transformItems` (bucket 3 function) + 'ais.clearRefinements': { + widget: clearRefinements, + async transformParams(parameters) { + return parameters; + }, + }, }, render: () => {}, dispose: () => {}, diff --git a/packages/toolbar/__tests__/ai-tools.test.ts b/packages/toolbar/__tests__/ai-tools.test.ts index 2006235..d821f26 100644 --- a/packages/toolbar/__tests__/ai-tools.test.ts +++ b/packages/toolbar/__tests__/ai-tools.test.ts @@ -48,6 +48,14 @@ describe('describeWidgetTypes', () => { expect(result).toContain('ais.chat ("Chat", default placement: body)'); }); + it('includes clearRefinements widget type', () => { + const result = describeWidgetTypes(); + expect(result).toContain('ais.clearRefinements'); + expect(result).toContain('Clear Refinements'); + expect(result).toContain('includedAttributes'); + expect(result).toContain('excludedAttributes'); + }); + it('excludes disabled widget types', () => { const result = describeWidgetTypes(); expect(result).not.toContain('ais.hits'); @@ -433,6 +441,37 @@ describe('getTools', () => { expect(callbacks.onAddBlock).toHaveBeenCalledWith('ais.autocomplete'); }); + it('adds clearRefinements widget with list parameters', async () => { + const experience: ExperienceApiResponse = { blocks: [] }; + const callbacks = createCallbacks(experience); + const tools = getTools(callbacks); + + const result = await tools.add_widget.execute!( + { + type: 'ais.clearRefinements', + container: '#clear', + parameters: { + includedAttributes: ['brand', 'color'], + }, + }, + { toolCallId: 'tc1', messages: [] } + ); + + expect(result).toMatchObject({ + success: true, + applied: expect.arrayContaining([ + 'placement', + 'container', + 'includedAttributes', + ]), + }); + expect(callbacks.onParameterChange).toHaveBeenCalledWith( + 0, + 'includedAttributes', + ['brand', 'color'] + ); + }); + it('computes the correct index for non-empty experiences', async () => { const experience: ExperienceApiResponse = { blocks: [ @@ -713,6 +752,52 @@ describe('getTools', () => { ); }); + it('applies list parameter changes on clearRefinements', async () => { + const experience: ExperienceApiResponse = { + blocks: [ + { + type: 'ais.clearRefinements', + parameters: { + container: '#clear', + includedAttributes: [], + excludedAttributes: [], + }, + }, + ], + }; + const callbacks = createCallbacks(experience); + const tools = getTools(callbacks); + + const result = await tools.edit_widget.execute!( + { + index: 0, + parameters: { + includedAttributes: ['brand'], + excludedAttributes: ['query'], + }, + }, + { toolCallId: 'tc1', messages: [] } + ); + + expect(result).toMatchObject({ + success: true, + applied: expect.arrayContaining([ + 'includedAttributes', + 'excludedAttributes', + ]), + }); + expect(callbacks.onParameterChange).toHaveBeenCalledWith( + 0, + 'includedAttributes', + ['brand'] + ); + expect(callbacks.onParameterChange).toHaveBeenCalledWith( + 0, + 'excludedAttributes', + ['query'] + ); + }); + it('returns empty applied when all parameters are rejected', async () => { const experience: ExperienceApiResponse = { blocks: [ diff --git a/packages/toolbar/__tests__/clearRefinements.test.tsx b/packages/toolbar/__tests__/clearRefinements.test.tsx new file mode 100644 index 0000000..61b07d8 --- /dev/null +++ b/packages/toolbar/__tests__/clearRefinements.test.tsx @@ -0,0 +1,166 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { fireInput, getSwitch, renderEditor } from './widget-test-utils'; + +afterEach(() => { + document.body.innerHTML = ''; +}); + +function render(params: Record = {}) { + return renderEditor(params, 'ais.clearRefinements'); +} + +describe('ais.clearRefinements fields', () => { + describe('includedAttributes', () => { + it('renders a toggle switch off by default', () => { + const { container } = render(); + const switchEl = getSwitch(container, 'Included attributes'); + expect(switchEl.getAttribute('aria-checked')).toBe('false'); + }); + + it('calls onParameterChange with empty array when toggling on', () => { + const { container, onParameterChange } = render(); + const switchEl = getSwitch(container, 'Included attributes'); + switchEl.click(); + + expect(onParameterChange).toHaveBeenCalledWith('includedAttributes', []); + }); + + it('calls onParameterChange with undefined when toggling off', () => { + const { container, onParameterChange } = render({ + includedAttributes: ['brand'], + }); + const switchEl = getSwitch(container, 'Included attributes'); + switchEl.click(); + + expect(onParameterChange).toHaveBeenCalledWith( + 'includedAttributes', + undefined + ); + }); + + it('renders existing items when enabled', () => { + const { container } = render({ + includedAttributes: ['brand', 'color'], + }); + const inputs = Array.from(container.querySelectorAll('input')).filter( + (input) => { + return input.value === 'brand' || input.value === 'color'; + } + ); + expect(inputs).toHaveLength(2); + }); + + it('calls onParameterChange with updated array when editing an item', () => { + const { container, onParameterChange } = render({ + includedAttributes: ['brand'], + }); + const inputs = Array.from(container.querySelectorAll('input')); + const brandInput = inputs.find((input) => { + return input.value === 'brand'; + })!; + fireInput(brandInput, 'category'); + + expect(onParameterChange).toHaveBeenCalledWith('includedAttributes', [ + 'category', + ]); + }); + + it('calls onParameterChange with a new item when clicking Add', () => { + const { container, onParameterChange } = render({ + includedAttributes: ['brand'], + }); + const addButtons = Array.from( + container.querySelectorAll('button') + ).filter((btn) => { + return btn.textContent?.trim() === 'Add'; + }); + addButtons[0]!.click(); + + expect(onParameterChange).toHaveBeenCalledWith('includedAttributes', [ + 'brand', + '', + ]); + }); + + it('calls onParameterChange without the item when clicking Remove', () => { + const { container, onParameterChange } = render({ + includedAttributes: ['brand', 'color'], + }); + const removeButtons = Array.from( + container.querySelectorAll('button[aria-label="Remove item"]') + ); + removeButtons[0]!.click(); + + expect(onParameterChange).toHaveBeenCalledWith('includedAttributes', [ + 'color', + ]); + }); + }); + + describe('excludedAttributes', () => { + it('renders a toggle switch off by default', () => { + const { container } = render(); + const switchEl = getSwitch(container, 'Excluded attributes'); + expect(switchEl.getAttribute('aria-checked')).toBe('false'); + }); + + it('calls onParameterChange with undefined when toggling off', () => { + const { container, onParameterChange } = render({ + excludedAttributes: ['query'], + }); + const switchEl = getSwitch(container, 'Excluded attributes'); + switchEl.click(); + + expect(onParameterChange).toHaveBeenCalledWith( + 'excludedAttributes', + undefined + ); + }); + + it('calls onParameterChange with updated array when editing an item', () => { + const { container, onParameterChange } = render({ + excludedAttributes: ['query'], + }); + const inputs = Array.from(container.querySelectorAll('input')); + const queryInput = inputs.find((input) => { + return input.value === 'query'; + })!; + fireInput(queryInput, 'price'); + + expect(onParameterChange).toHaveBeenCalledWith('excludedAttributes', [ + 'price', + ]); + }); + }); + + describe('cssClasses', () => { + it('renders a toggle for CSS classes', () => { + const { container } = render(); + const switchEl = getSwitch(container, 'CSS classes'); + expect(switchEl).not.toBeNull(); + }); + + it('calls onParameterChange with default value when toggling on', () => { + const { container, onParameterChange } = render(); + const switchEl = getSwitch(container, 'CSS classes'); + switchEl.click(); + + expect(onParameterChange).toHaveBeenCalledWith('cssClasses', { + root: '', + button: '', + disabledButton: '', + }); + }); + + it('calls onParameterChange with undefined when toggling off', () => { + const { container, onParameterChange } = render({ + cssClasses: { root: 'my-root', button: '', disabledButton: '' }, + }); + const switchEl = getSwitch(container, 'CSS classes'); + switchEl.click(); + + expect(onParameterChange).toHaveBeenCalledWith('cssClasses', undefined); + }); + }); +}); diff --git a/packages/toolbar/src/components/block-editor.tsx b/packages/toolbar/src/components/block-editor.tsx index e941d7a..aa6941b 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 { ListField } from './fields/list-field'; import { NumberField } from './fields/number-field'; import { ObjectField } from './fields/object-field'; import { PlacementField } from './fields/placement-field'; @@ -163,6 +164,25 @@ export function BlockEditor({ onPickElement={onPickElement} /> ); + case 'list': { + const enabled = Array.isArray(value); + const items = enabled ? (value as string[]) : []; + return ( + { + return onParameterChange(key, toggled); + }} + onItemsChange={(newItems) => { + return onParameterChange(key, newItems); + }} + /> + ); + } case 'object': { const enabled = typeof value === 'object' && value !== null; const objectValue = enabled diff --git a/packages/toolbar/src/components/fields/list-field.tsx b/packages/toolbar/src/components/fields/list-field.tsx new file mode 100644 index 0000000..446749e --- /dev/null +++ b/packages/toolbar/src/components/fields/list-field.tsx @@ -0,0 +1,112 @@ +import { useId } from 'preact/hooks'; + +import { Button } from '../ui/button'; +import { CollapsibleContent } from '../ui/collapsible'; +import { Input } from '../ui/input'; +import { Label } from '../ui/label'; +import { Switch } from '../ui/switch'; + +type ListFieldProps = { + label: string; + enabled: boolean; + items: string[]; + placeholder?: string; + onToggle: (value: string[] | undefined) => void; + onItemsChange: (items: string[]) => void; +}; + +export function ListField({ + label, + enabled, + items, + placeholder, + onToggle, + onItemsChange, +}: ListFieldProps) { + const id = useId(); + + return ( +
+
+ + { + return onToggle(checked ? [] : undefined); + }} + /> +
+ +
+ {items.map((item, index) => { + return ( +
+ { + const next = [...items]; + next[index] = (event.target as HTMLInputElement).value; + onItemsChange(next); + }} + /> + +
+ ); + })} + +
+
+
+ ); +} diff --git a/packages/toolbar/src/widget-types.tsx b/packages/toolbar/src/widget-types.tsx index 873cc92..cf080b6 100644 --- a/packages/toolbar/src/widget-types.tsx +++ b/packages/toolbar/src/widget-types.tsx @@ -17,7 +17,8 @@ export type FieldOverride = defaultValue: Record; disabledValue?: false | undefined; fields: Array<{ key: string; label: string }>; - }; + } + | { type: 'list'; label: string; placeholder?: string }; export type WidgetTypeConfig = { label: string; @@ -221,6 +222,21 @@ const CART_ICON = ( ); +const X_ICON = ( + + + + +); + export const WIDGET_TYPES: Record = { 'ais.autocomplete': { label: 'Autocomplete', @@ -371,6 +387,67 @@ export const WIDGET_TYPES: Record = { container: '', }, }, + 'ais.clearRefinements': { + label: 'Clear Refinements', + description: + 'A button that lets users remove all active filters and refinements at once.', + enabled: true, + icon: X_ICON, + defaultParameters: { + container: '', + includedAttributes: undefined, + excludedAttributes: undefined, + cssClasses: undefined, + }, + fieldOrder: [ + 'container', + 'placement', + 'includedAttributes', + 'excludedAttributes', + 'cssClasses', + ], + fieldOverrides: { + includedAttributes: { + type: 'list', + label: 'Included attributes', + placeholder: 'e.g. brand', + }, + excludedAttributes: { + type: 'list', + label: 'Excluded attributes', + placeholder: 'e.g. query', + }, + cssClasses: { + type: 'object', + label: 'CSS classes', + defaultValue: { + root: '', + button: '', + disabledButton: '', + }, + disabledValue: undefined, + fields: [ + { key: 'root', label: 'Root' }, + { key: 'button', label: 'Button' }, + { key: 'disabledButton', label: 'Disabled button' }, + ], + }, + }, + paramLabels: { + container: 'Container', + includedAttributes: 'Included attributes', + excludedAttributes: 'Excluded attributes', + }, + paramDescriptions: { + container: + 'CSS selector for the DOM element to render into (e.g. "#clear-refinements").', + includedAttributes: + 'Only clear refinements from these attributes. When empty, all refinements are clearable.', + excludedAttributes: 'Never clear refinements from these attributes.', + cssClasses: + 'Custom CSS classes to apply to the widget elements for styling.', + }, + }, 'ais.frequentlyBoughtTogether': { label: 'Frequently Bought Together', enabled: false,