diff --git a/packages/runtime/src/experiences/types.ts b/packages/runtime/src/experiences/types.ts index 3965005..d0a211f 100644 --- a/packages/runtime/src/experiences/types.ts +++ b/packages/runtime/src/experiences/types.ts @@ -66,6 +66,7 @@ export type ExperienceWidget = Widget & { 'ais.hits': SupportedWidget[0]>; 'ais.infiniteHits': SupportedWidget[0]>; 'ais.pagination': SupportedWidget[0]>; + 'ais.refinementList': SupportedWidget; 'ais.searchBox': SupportedWidget[0]>; 'ais.stats': SupportedWidget[0]>; } & Record< @@ -76,6 +77,7 @@ export type ExperienceWidget = Widget & { | 'ais.hits' | 'ais.infiniteHits' | 'ais.pagination' + | 'ais.refinementList' | 'ais.searchBox' | 'ais.stats' | (string & {}), diff --git a/packages/runtime/src/experiences/widget.tsx b/packages/runtime/src/experiences/widget.tsx index 49d344f..449498e 100644 --- a/packages/runtime/src/experiences/widget.tsx +++ b/packages/runtime/src/experiences/widget.tsx @@ -10,6 +10,7 @@ import infiniteHits from 'instantsearch.js/es/widgets/infinite-hits/infinite-hit import { EXPERIMENTAL_autocomplete } from 'instantsearch.js/es/widgets/autocomplete/autocomplete'; import clearRefinements from 'instantsearch.js/es/widgets/clear-refinements/clear-refinements'; import pagination from 'instantsearch.js/es/widgets/pagination/pagination'; +import refinementList from 'instantsearch.js/es/widgets/refinement-list/refinement-list'; import searchBox from 'instantsearch.js/es/widgets/search-box/search-box'; import stats from 'instantsearch.js/es/widgets/stats/stats'; @@ -177,6 +178,14 @@ export default (function experience(widgetParams: ExperienceWidgetParams) { return parameters; }, }, + // TODO: Add support for `templates` (item, showMoreText, searchableNoResults, searchableSubmit, searchableReset, searchableLoadingIndicator) + // TODO: Add support for `transformItems` (bucket 3 function) + 'ais.refinementList': { + widget: refinementList, + async transformParams(parameters) { + return parameters; + }, + }, // TODO: Add support for `templates` (submit, reset, loadingIndicator) // TODO: Add support for `queryHook` (bucket 3 — function) 'ais.searchBox': { diff --git a/packages/toolbar/__tests__/ai-tools.test.ts b/packages/toolbar/__tests__/ai-tools.test.ts index c7ef93f..b4a2ac3 100644 --- a/packages/toolbar/__tests__/ai-tools.test.ts +++ b/packages/toolbar/__tests__/ai-tools.test.ts @@ -90,9 +90,12 @@ describe('describeWidgetTypes', () => { expect(result).toContain('excludedAttributes'); }); - it('excludes disabled widget types', () => { + it('includes refinementList widget type', () => { const result = describeWidgetTypes(); - expect(result).not.toContain('ais.refinementList'); + expect(result).toContain('ais.refinementList'); + expect(result).toContain('Refinement List'); + expect(result).toContain('attribute'); + expect(result).toContain('searchable'); }); }); @@ -844,6 +847,44 @@ describe('getTools', () => { }); }); + it('adds refinementList widget with parameters', async () => { + const experience: ExperienceApiResponse = { + blocks: [], + indexName: '', + }; + const callbacks = createCallbacks(experience); + const tools = getTools(callbacks); + + const result = await tools.add_widget.execute!( + { + type: 'ais.refinementList', + container: '#filters', + parameters: { attribute: 'brand', searchable: true }, + }, + { toolCallId: 'tc1', messages: [] } + ); + + expect(result).toMatchObject({ + success: true, + applied: expect.arrayContaining([ + 'placement', + 'container', + 'attribute', + 'searchable', + ]), + }); + expect(callbacks.onParameterChange).toHaveBeenCalledWith( + [0], + 'attribute', + 'brand' + ); + expect(callbacks.onParameterChange).toHaveBeenCalledWith( + [0], + 'searchable', + true + ); + }); + it('computes the correct index for non-empty experiences', async () => { const experience: ExperienceApiResponse = { blocks: [ @@ -1533,6 +1574,48 @@ describe('getTools', () => { ); }); + it('applies parameter changes on refinementList', async () => { + const experience: ExperienceApiResponse = { + blocks: [ + { + type: 'ais.refinementList', + parameters: { + container: '#filters', + attribute: 'brand', + searchable: false, + }, + }, + ], + indexName: '', + }; + const callbacks = createCallbacks(experience); + const tools = getTools(callbacks); + + const result = await tools.edit_widget.execute!( + { + path: '0', + parameters: { attribute: 'color', showMore: true, limit: 5 }, + }, + { toolCallId: 'tc1', messages: [] } + ); + + expect(result).toMatchObject({ + success: true, + applied: expect.arrayContaining(['attribute', 'showMore', 'limit']), + }); + expect(callbacks.onParameterChange).toHaveBeenCalledWith( + [0], + 'attribute', + 'color' + ); + expect(callbacks.onParameterChange).toHaveBeenCalledWith( + [0], + 'showMore', + true + ); + expect(callbacks.onParameterChange).toHaveBeenCalledWith([0], 'limit', 5); + }); + it('returns empty applied when all parameters are rejected', async () => { const experience: ExperienceApiResponse = { blocks: [ diff --git a/packages/toolbar/__tests__/refinementList.test.tsx b/packages/toolbar/__tests__/refinementList.test.tsx new file mode 100644 index 0000000..ecfbc1d --- /dev/null +++ b/packages/toolbar/__tests__/refinementList.test.tsx @@ -0,0 +1,327 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { + fireInput, + getInput, + getSwitch, + renderEditor, +} from './widget-test-utils'; + +afterEach(() => { + document.body.innerHTML = ''; +}); + +function render(params: Record = {}) { + return renderEditor(params, 'ais.refinementList'); +} + +describe('ais.refinementList fields', () => { + describe('attribute', () => { + it('renders an empty text input by default', () => { + const { container } = render({ attribute: '' }); + const input = getInput(container, 'Attribute'); + expect(input.value).toBe(''); + }); + + it('calls onParameterChange when editing', () => { + const { container, onParameterChange } = render({ attribute: '' }); + const input = getInput(container, 'Attribute'); + fireInput(input, 'brand'); + + expect(onParameterChange).toHaveBeenCalledWith('attribute', 'brand'); + }); + }); + + describe('operator', () => { + it('selects the default option when absent', () => { + const { container } = render(); + const selected = container.querySelector( + '[data-slot="tabs-trigger"][aria-selected="true"]' + ); + expect(selected?.textContent).toBe('or'); + }); + + it('calls onParameterChange with "and" when selecting "and"', () => { + const { container, onParameterChange } = render(); + const triggers = Array.from( + container.querySelectorAll( + '[data-slot="tabs-trigger"]' + ) + ); + const andTrigger = triggers.find((el) => { + return el.textContent === 'and'; + })!; + andTrigger.click(); + + expect(onParameterChange).toHaveBeenCalledWith('operator', 'and'); + }); + + it('calls onParameterChange with undefined when selecting the default', () => { + const { container, onParameterChange } = render({ operator: 'and' }); + const triggers = Array.from( + container.querySelectorAll( + '[data-slot="tabs-trigger"]' + ) + ); + const orTrigger = triggers.find((el) => { + return el.textContent === 'or'; + })!; + orTrigger.click(); + + expect(onParameterChange).toHaveBeenCalledWith('operator', undefined); + }); + }); + + describe('limit', () => { + it('renders with placeholder when absent', () => { + const { container } = render(); + const input = getInput(container, 'Limit'); + expect(input.value).toBe(''); + expect(input.placeholder).toBe('10'); + }); + + it('calls onParameterChange with number when set', () => { + const { container, onParameterChange } = render(); + const input = getInput(container, 'Limit'); + fireInput(input, '5'); + + expect(onParameterChange).toHaveBeenCalledWith('limit', 5); + }); + + it('calls onParameterChange with undefined when cleared', () => { + const { container, onParameterChange } = render({ limit: 5 }); + const input = getInput(container, 'Limit'); + fireInput(input, ''); + + expect(onParameterChange).toHaveBeenCalledWith('limit', undefined); + }); + }); + + describe('showMore', () => { + it('renders a switch off by default', () => { + const { container } = render(); + const switchEl = getSwitch(container, 'Show more'); + expect(switchEl.getAttribute('aria-checked')).toBe('false'); + }); + + it('calls onParameterChange when toggled on', () => { + const { container, onParameterChange } = render(); + const switchEl = getSwitch(container, 'Show more'); + switchEl.click(); + + expect(onParameterChange).toHaveBeenCalledWith('showMore', true); + }); + + it('clears showMoreLimit when toggled off', () => { + const { container, onParameterChange } = render({ + showMore: true, + showMoreLimit: 50, + }); + const switchEl = getSwitch(container, 'Show more'); + switchEl.click(); + + expect(onParameterChange).toHaveBeenCalledWith('showMore', false); + expect(onParameterChange).toHaveBeenCalledWith( + 'showMoreLimit', + undefined + ); + }); + }); + + describe('showMoreLimit', () => { + it('is hidden when showMore is false', () => { + const { container } = render(); + const labels = Array.from(container.querySelectorAll('label')); + const label = labels.find((el) => { + return el.textContent?.trim() === 'Show more limit'; + }); + expect(label).toBeUndefined(); + }); + + it('renders with placeholder when showMore is true', () => { + const { container } = render({ showMore: true }); + const input = getInput(container, 'Show more limit'); + expect(input.placeholder).toBe('20'); + }); + + it('calls onParameterChange with number when set', () => { + const { container, onParameterChange } = render({ showMore: true }); + const input = getInput(container, 'Show more limit'); + fireInput(input, '50'); + + expect(onParameterChange).toHaveBeenCalledWith('showMoreLimit', 50); + }); + }); + + describe('searchable', () => { + it('renders a switch off by default', () => { + const { container } = render(); + const switchEl = getSwitch(container, 'Searchable'); + expect(switchEl.getAttribute('aria-checked')).toBe('false'); + }); + + it('calls onParameterChange when toggled on', () => { + const { container, onParameterChange } = render(); + const switchEl = getSwitch(container, 'Searchable'); + switchEl.click(); + + expect(onParameterChange).toHaveBeenCalledWith('searchable', true); + }); + + it('clears dependent params when toggled off', () => { + const { container, onParameterChange } = render({ + searchable: true, + searchablePlaceholder: 'Find...', + searchableIsAlwaysActive: true, + searchableEscapeFacetValues: true, + }); + const switchEl = getSwitch(container, 'Searchable'); + switchEl.click(); + + expect(onParameterChange).toHaveBeenCalledWith('searchable', false); + expect(onParameterChange).toHaveBeenCalledWith( + 'searchablePlaceholder', + undefined + ); + expect(onParameterChange).toHaveBeenCalledWith( + 'searchableIsAlwaysActive', + undefined + ); + expect(onParameterChange).toHaveBeenCalledWith( + 'searchableEscapeFacetValues', + undefined + ); + expect(onParameterChange).toHaveBeenCalledWith( + 'searchableSelectOnSubmit', + undefined + ); + }); + }); + + describe('searchablePlaceholder', () => { + it('is hidden when searchable is false', () => { + const { container } = render(); + const labels = Array.from(container.querySelectorAll('label')); + const label = labels.find((el) => { + return el.textContent?.trim() === 'Search placeholder'; + }); + expect(label).toBeUndefined(); + }); + + it('renders with placeholder when searchable is true', () => { + const { container } = render({ searchable: true }); + const input = getInput(container, 'Search placeholder'); + expect(input.placeholder).toBe('Search...'); + }); + + it('calls onParameterChange with undefined when cleared', () => { + const { container, onParameterChange } = render({ + searchable: true, + searchablePlaceholder: 'Find...', + }); + const input = getInput(container, 'Search placeholder'); + fireInput(input, ''); + + expect(onParameterChange).toHaveBeenCalledWith( + 'searchablePlaceholder', + undefined + ); + }); + }); + + describe('searchableIsAlwaysActive', () => { + it('is hidden when searchable is false', () => { + const { container } = render(); + const labels = Array.from(container.querySelectorAll('label')); + const label = labels.find((el) => { + return el.textContent?.trim() === 'Search always active'; + }); + expect(label).toBeUndefined(); + }); + + it('renders a switch on when the parameter is true', () => { + const { container } = render({ + searchable: true, + searchableIsAlwaysActive: true, + }); + const switchEl = getSwitch(container, 'Search always active'); + expect(switchEl.getAttribute('aria-checked')).toBe('true'); + }); + }); + + describe('searchableEscapeFacetValues', () => { + it('is hidden when searchable is false', () => { + const { container } = render(); + const labels = Array.from(container.querySelectorAll('label')); + const label = labels.find((el) => { + return el.textContent?.trim() === 'Escape search facet values'; + }); + expect(label).toBeUndefined(); + }); + + it('renders a switch on when the parameter is true', () => { + const { container } = render({ + searchable: true, + searchableEscapeFacetValues: true, + }); + const switchEl = getSwitch(container, 'Escape search facet values'); + expect(switchEl.getAttribute('aria-checked')).toBe('true'); + }); + }); + + describe('searchableSelectOnSubmit', () => { + it('is hidden when searchable is false', () => { + const { container } = render(); + const labels = Array.from(container.querySelectorAll('label')); + const label = labels.find((el) => { + return el.textContent?.trim() === 'Select on submit'; + }); + expect(label).toBeUndefined(); + }); + + it('renders a switch when searchable is true', () => { + const { container } = render({ searchable: true }); + const switchEl = getSwitch(container, 'Select on submit'); + expect(switchEl).not.toBeNull(); + }); + }); + + describe('cssClasses', () => { + it('renders a toggle off by default', () => { + const { container } = render(); + const switchEl = getSwitch(container, 'CSS classes'); + expect(switchEl.getAttribute('aria-checked')).toBe('false'); + }); + + 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: '', + noRefinementRoot: '', + list: '', + item: '', + selectedItem: '', + label: '', + checkbox: '', + labelText: '', + showMore: '', + disabledShowMore: '', + count: '', + searchBox: '', + }); + }); + + it('calls onParameterChange with undefined when toggling off', () => { + const { container, onParameterChange } = render({ + cssClasses: { root: 'my-root' }, + }); + const switchEl = getSwitch(container, 'CSS classes'); + switchEl.click(); + + expect(onParameterChange).toHaveBeenCalledWith('cssClasses', undefined); + }); + }); +}); diff --git a/packages/toolbar/__tests__/toolbar.test.ts b/packages/toolbar/__tests__/toolbar.test.ts index 0671eed..8dae2e1 100644 --- a/packages/toolbar/__tests__/toolbar.test.ts +++ b/packages/toolbar/__tests__/toolbar.test.ts @@ -222,20 +222,18 @@ describe('toolbar', () => { }); // Disabled items are rendered as
elements, not