diff --git a/package.json b/package.json index 810fc43368..705af71d66 100644 --- a/package.json +++ b/package.json @@ -130,19 +130,19 @@ }, { "path": "packages/react-instantsearch/dist/umd/Connectors.min.js", - "maxSize": "40 kB" + "maxSize": "40.25 kB" }, { "path": "packages/react-instantsearch/dist/umd/Dom.min.js", - "maxSize": "63 kB" + "maxSize": "63.75 kB" }, { "path": "packages/react-instantsearch-core/dist/umd/ReactInstantSearchCore.min.js", - "maxSize": "41 kB" + "maxSize": "41.25 kB" }, { "path": "packages/react-instantsearch-dom/dist/umd/ReactInstantSearchDOM.min.js", - "maxSize": "63 kB" + "maxSize": "63.25 kB" }, { "path": "packages/react-instantsearch-dom-maps/dist/umd/ReactInstantSearchDOMMaps.min.js", diff --git a/packages/react-instantsearch-core/src/connectors/__tests__/connectQueryRules.ts b/packages/react-instantsearch-core/src/connectors/__tests__/connectQueryRules.ts index 8f6d2a2c98..58fc9f5dc1 100644 --- a/packages/react-instantsearch-core/src/connectors/__tests__/connectQueryRules.ts +++ b/packages/react-instantsearch-core/src/connectors/__tests__/connectQueryRules.ts @@ -1,17 +1,25 @@ +import { SearchParameters } from 'algoliasearch-helper'; import connect, { QueryRulesProps } from '../connectQueryRules'; jest.mock('../../core/createConnector', () => (connector: any) => connector); describe('connectQueryRules', () => { + const defaultProps: QueryRulesProps = { + transformItems: items => items, + trackedFilters: {}, + transformRuleContexts: ruleContexts => ruleContexts, + }; + describe('single index', () => { const indexName = 'index'; const context = { context: { ais: { mainTargetedIndex: indexName } } }; const getProvidedProps = connect.getProvidedProps.bind(context); + const getSearchParameters = connect.getSearchParameters.bind(context); - describe('without userData', () => { - it('provides the correct props to the component', () => { + describe('default', () => { + it('without userData provides the correct props to the component', () => { const props: QueryRulesProps = { - transformItems: items => items, + ...defaultProps, }; const searchState = {}; const searchResults = { @@ -23,12 +31,10 @@ describe('connectQueryRules', () => { canRefine: false, }); }); - }); - describe('with userData', () => { - it('provides the correct props to the component', () => { + it('with userData provides the correct props to the component', () => { const props: QueryRulesProps = { - transformItems: items => items, + ...defaultProps, }; const searchState = {}; const searchResults = { @@ -42,12 +48,15 @@ describe('connectQueryRules', () => { canRefine: true, }); }); + }); + describe('transformItems', () => { it('transforms items before passing the props to the component', () => { const transformItemsSpy = jest.fn(() => [ { banner: 'image-transformed.png' }, ]); const props: QueryRulesProps = { + ...defaultProps, transformItems: transformItemsSpy, }; const searchState = {}; @@ -67,6 +76,354 @@ describe('connectQueryRules', () => { ]); }); }); + + describe('trackedFilters', () => { + it('does not set ruleContexts without search state and trackedFilters', () => { + const props: QueryRulesProps = { + ...defaultProps, + }; + const searchState = {}; + const searchParameters = getSearchParameters( + new SearchParameters(), + props, + searchState + ); + + expect(searchParameters.ruleContexts).toEqual(undefined); + }); + + it('does not set ruleContexts with search state but without tracked filters', () => { + const props: QueryRulesProps = { + ...defaultProps, + }; + const searchState = { + range: { + price: { + min: 20, + max: 3000, + }, + }, + }; + const searchParameters = getSearchParameters( + new SearchParameters(), + props, + searchState + ); + + expect(searchParameters.ruleContexts).toEqual(undefined); + }); + + it('does not reset initial ruleContexts with trackedFilters', () => { + const props: QueryRulesProps = { + ...defaultProps, + trackedFilters: { + price: values => values, + }, + }; + const searchState = {}; + const searchParameters = getSearchParameters( + SearchParameters.make({ + ruleContexts: ['initial-rule'], + }), + props, + searchState + ); + + expect(searchParameters.ruleContexts).toEqual(['initial-rule']); + }); + + it('sets ruleContexts based on range', () => { + const priceSpy = jest.fn(values => values); + const props: QueryRulesProps = { + ...defaultProps, + trackedFilters: { + price: priceSpy, + }, + }; + const searchState = { + range: { + price: { + min: 20, + max: 3000, + }, + }, + }; + const searchParameters = getSearchParameters( + new SearchParameters(), + props, + searchState + ); + + expect(priceSpy).toHaveBeenCalledTimes(1); + expect(priceSpy).toHaveBeenCalledWith([20, 3000]); + expect(searchParameters.ruleContexts).toEqual([ + 'ais-price-20', + 'ais-price-3000', + ]); + }); + + it('sets ruleContexts based on refinementList', () => { + const fruitSpy = jest.fn(values => values); + const props: QueryRulesProps = { + ...defaultProps, + trackedFilters: { + fruit: fruitSpy, + }, + }; + const searchState = { + refinementList: { + fruit: ['lemon', 'orange'], + }, + }; + const searchParameters = getSearchParameters( + new SearchParameters(), + props, + searchState + ); + + expect(fruitSpy).toHaveBeenCalledTimes(1); + expect(fruitSpy).toHaveBeenCalledWith(['lemon', 'orange']); + expect(searchParameters.ruleContexts).toEqual([ + 'ais-fruit-lemon', + 'ais-fruit-orange', + ]); + }); + + it('sets ruleContexts based on hierarchicalMenu', () => { + const productsSpy = jest.fn(values => values); + const props: QueryRulesProps = { + ...defaultProps, + trackedFilters: { + products: productsSpy, + }, + }; + const searchState = { + hierarchicalMenu: { + products: 'Laptops > Surface', + }, + }; + const searchParameters = getSearchParameters( + new SearchParameters(), + props, + searchState + ); + + expect(productsSpy).toHaveBeenCalledTimes(1); + expect(productsSpy).toHaveBeenCalledWith(['Laptops > Surface']); + expect(searchParameters.ruleContexts).toEqual([ + 'ais-products-Laptops_Surface', + ]); + }); + + it('sets ruleContexts based on menu', () => { + const brandsSpy = jest.fn(values => values); + const props: QueryRulesProps = { + ...defaultProps, + trackedFilters: { + brands: brandsSpy, + }, + }; + const searchState = { + menu: { + brands: 'Sony', + }, + }; + const searchParameters = getSearchParameters( + new SearchParameters(), + props, + searchState + ); + + expect(brandsSpy).toHaveBeenCalledTimes(1); + expect(brandsSpy).toHaveBeenCalledWith(['Sony']); + expect(searchParameters.ruleContexts).toEqual(['ais-brands-Sony']); + }); + + it('sets ruleContexts based on multiRange', () => { + const rankSpy = jest.fn(values => values); + const props: QueryRulesProps = { + ...defaultProps, + trackedFilters: { + rank: rankSpy, + }, + }; + const searchState = { + multiRange: { + rank: '2:5', + }, + }; + const searchParameters = getSearchParameters( + new SearchParameters(), + props, + searchState + ); + + expect(rankSpy).toHaveBeenCalledTimes(1); + expect(rankSpy).toHaveBeenCalledWith(['2', '5']); + expect(searchParameters.ruleContexts).toEqual([ + 'ais-rank-2', + 'ais-rank-5', + ]); + }); + + it('sets ruleContexts based on toggle', () => { + const freeShippingSpy = jest.fn(values => values); + const props: QueryRulesProps = { + ...defaultProps, + trackedFilters: { + freeShipping: freeShippingSpy, + }, + }; + const searchState = { + toggle: { + freeShipping: true, + }, + }; + const searchParameters = getSearchParameters( + new SearchParameters(), + props, + searchState + ); + + expect(freeShippingSpy).toHaveBeenCalledTimes(1); + expect(freeShippingSpy).toHaveBeenCalledWith([true]); + expect(searchParameters.ruleContexts).toEqual([ + 'ais-freeShipping-true', + ]); + }); + + it('escapes all rule contexts before passing them to search parameters', () => { + const brandSpy = jest.fn(values => values); + const props: QueryRulesProps = { + ...defaultProps, + trackedFilters: { + brand: brandSpy, + }, + }; + const searchState = { + refinementList: { + brand: ['Insignia™', '© Apple'], + }, + }; + const searchParameters = getSearchParameters( + new SearchParameters(), + props, + searchState + ); + + expect(brandSpy).toHaveBeenCalledTimes(1); + expect(brandSpy).toHaveBeenCalledWith(['Insignia™', '© Apple']); + expect(searchParameters.ruleContexts).toEqual([ + 'ais-brand-Insignia_', + 'ais-brand-_Apple', + ]); + }); + + it('slices and warns in development when more than 10 rule contexts are applied', () => { + const brandFacetRefinements = [ + 'Insignia', + 'Canon', + 'Dynex', + 'LG', + 'Metra', + 'Sony', + 'HP', + 'Apple', + 'Samsung', + 'Speck', + 'PNY', + ]; + + expect(brandFacetRefinements).toHaveLength(11); + + const brandSpy = jest.fn(values => values); + const props: QueryRulesProps = { + ...defaultProps, + trackedFilters: { + brand: brandSpy, + }, + }; + const searchState = { + refinementList: { + brand: brandFacetRefinements, + }, + }; + const searchParameters = getSearchParameters( + new SearchParameters(), + props, + searchState + ); + + if (process.env.NODE_ENV === 'development') { + const warnSpy = jest.spyOn(console, 'warn'); + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy) + .toHaveBeenCalledWith(`The maximum number of \`ruleContexts\` is 10. They have been sliced to that limit. +Consider using \`transformRuleContexts\` to minimize the number of rules sent to Algolia.`); + } + + expect(brandSpy).toHaveBeenCalledTimes(1); + expect(brandSpy).toHaveBeenCalledWith([ + 'Insignia', + 'Canon', + 'Dynex', + 'LG', + 'Metra', + 'Sony', + 'HP', + 'Apple', + 'Samsung', + 'Speck', + 'PNY', + ]); + expect(searchParameters.ruleContexts).toEqual([ + 'ais-brand-Insignia', + 'ais-brand-Canon', + 'ais-brand-Dynex', + 'ais-brand-LG', + 'ais-brand-Metra', + 'ais-brand-Sony', + 'ais-brand-HP', + 'ais-brand-Apple', + 'ais-brand-Samsung', + 'ais-brand-Speck', + ]); + }); + }); + + describe('transformRuleContexts', () => { + it('transform rule contexts before adding them to search parameters', () => { + const priceSpy = jest.fn(values => values); + const props: QueryRulesProps = { + ...defaultProps, + trackedFilters: { + price: priceSpy, + }, + transformRuleContexts: rules => + rules.map(rule => rule.replace('ais-', 'transformed-')), + }; + const searchState = { + range: { + price: { + min: 20, + max: 3000, + }, + }, + }; + const searchParameters = getSearchParameters( + new SearchParameters(), + props, + searchState + ); + + expect(priceSpy).toHaveBeenCalledTimes(1); + expect(priceSpy).toHaveBeenCalledWith([20, 3000]); + expect(searchParameters.ruleContexts).toEqual([ + 'transformed-price-20', + 'transformed-price-3000', + ]); + }); + }); }); describe('multi index', () => { @@ -79,47 +436,47 @@ describe('connectQueryRules', () => { }, }; const getProvidedProps = connect.getProvidedProps.bind(context); + const getSearchParameters = connect.getSearchParameters.bind(context); - describe('without userData', () => { - it('provides the correct props to the component', () => { - const props: QueryRulesProps = { - transformItems: items => items, - }; - const searchState = {}; - const searchResults = { - results: { [secondIndexName]: { userData: undefined } }, - }; + it('without userData provides the correct props to the component', () => { + const props: QueryRulesProps = { + ...defaultProps, + }; + const searchState = {}; + const searchResults = { + results: { [secondIndexName]: { userData: undefined } }, + }; - expect(getProvidedProps(props, searchState, searchResults)).toEqual({ - items: [], - canRefine: false, - }); + expect(getProvidedProps(props, searchState, searchResults)).toEqual({ + items: [], + canRefine: false, }); }); - describe('with userData', () => { - it('provides the correct props to the component', () => { - const props: QueryRulesProps = { - transformItems: items => items, - }; - const searchState = {}; - const searchResults = { - results: { - [secondIndexName]: { userData: [{ banner: 'image.png' }] }, - }, - }; + it('with userData provides the correct props to the component', () => { + const props: QueryRulesProps = { + ...defaultProps, + }; + const searchState = {}; + const searchResults = { + results: { + [secondIndexName]: { userData: [{ banner: 'image.png' }] }, + }, + }; - expect(getProvidedProps(props, searchState, searchResults)).toEqual({ - items: [{ banner: 'image.png' }], - canRefine: true, - }); + expect(getProvidedProps(props, searchState, searchResults)).toEqual({ + items: [{ banner: 'image.png' }], + canRefine: true, }); + }); + describe('transformItems', () => { it('transforms items before passing the props to the component', () => { const transformItemsSpy = jest.fn(() => [ { banner: 'image-transformed.png' }, ]); const props: QueryRulesProps = { + ...defaultProps, transformItems: transformItemsSpy, }; const searchState = {}; @@ -138,5 +495,118 @@ describe('connectQueryRules', () => { ]); }); }); + + describe('trackedFilters', () => { + it('does not set ruleContexts without search state and trackedFilters', () => { + const props: QueryRulesProps = { + ...defaultProps, + }; + const searchState = {}; + const searchParameters = getSearchParameters( + new SearchParameters(), + props, + searchState + ); + + expect(searchParameters.ruleContexts).toEqual(undefined); + }); + + it('does not set ruleContexts with search state but without tracked filters', () => { + const props: QueryRulesProps = { + ...defaultProps, + }; + const searchState = { + indices: { + [secondIndexName]: { + range: { + price: { + min: 20, + max: 3000, + }, + }, + }, + }, + }; + const searchParameters = getSearchParameters( + new SearchParameters(), + props, + searchState + ); + + expect(searchParameters.ruleContexts).toEqual(undefined); + }); + + it('sets ruleContexts based on range', () => { + const priceSpy = jest.fn(values => values); + const props: QueryRulesProps = { + ...defaultProps, + trackedFilters: { + price: priceSpy, + }, + }; + const searchState = { + indices: { + [secondIndexName]: { + range: { + price: { + min: 20, + max: 3000, + }, + }, + }, + }, + }; + const searchParameters = getSearchParameters( + new SearchParameters(), + props, + searchState + ); + + expect(priceSpy).toHaveBeenCalledTimes(1); + expect(priceSpy).toHaveBeenCalledWith([20, 3000]); + expect(searchParameters.ruleContexts).toEqual([ + 'ais-price-20', + 'ais-price-3000', + ]); + }); + }); + + describe('transformRuleContexts', () => { + it('transform rule contexts before adding them to search parameters', () => { + const priceSpy = jest.fn(values => values); + const props: QueryRulesProps = { + ...defaultProps, + trackedFilters: { + price: priceSpy, + }, + transformRuleContexts: rules => + rules.map(rule => rule.replace('ais-', 'transformed-')), + }; + const searchState = { + indices: { + [secondIndexName]: { + range: { + price: { + min: 20, + max: 3000, + }, + }, + }, + }, + }; + const searchParameters = getSearchParameters( + new SearchParameters(), + props, + searchState + ); + + expect(priceSpy).toHaveBeenCalledTimes(1); + expect(priceSpy).toHaveBeenCalledWith([20, 3000]); + expect(searchParameters.ruleContexts).toEqual([ + 'transformed-price-20', + 'transformed-price-3000', + ]); + }); + }); }); }); diff --git a/packages/react-instantsearch-core/src/connectors/connectQueryRules.ts b/packages/react-instantsearch-core/src/connectors/connectQueryRules.ts index 322ae39dba..871d6fc195 100644 --- a/packages/react-instantsearch-core/src/connectors/connectQueryRules.ts +++ b/packages/react-instantsearch-core/src/connectors/connectQueryRules.ts @@ -1,19 +1,118 @@ import createConnector from '../core/createConnector'; -import { getResults } from '../core/indexUtils'; +import { getResults, getIndexId, hasMultipleIndices } from '../core/indexUtils'; + +type SearchState = any; + +type SearchParameters = any; export type CustomUserData = { [key: string]: any; }; +type TrackedFilterRefinement = string | number | boolean; + export type QueryRulesProps = { + trackedFilters: { + [facetName: string]: ( + facetValues: TrackedFilterRefinement[] + ) => TrackedFilterRefinement[]; + }; + transformRuleContexts: (ruleContexts: string[]) => string[]; transformItems: (items: TItem[]) => TItem[]; }; +// A context rule must consist only of alphanumeric characters, hyphens, and underscores. +// See https://www.algolia.com/doc/guides/managing-results/refine-results/merchandising-and-promoting/in-depth/implementing-query-rules/#context +function escapeRuleContext(ruleName: string): string { + return ruleName.replace(/[^a-z0-9-_]+/gi, '_'); +} + +function getWidgetRefinements( + attribute: string, + widgetKey: string, + searchState: SearchState +): TrackedFilterRefinement[] { + const widgetState = searchState[widgetKey]; + + switch (widgetKey) { + case 'range': + return Object.keys(widgetState[attribute]).map( + rangeKey => widgetState[attribute][rangeKey] + ); + + case 'refinementList': + return widgetState[attribute]; + + case 'hierarchicalMenu': + return [widgetState[attribute]]; + + case 'menu': + return [widgetState[attribute]]; + + case 'multiRange': + return widgetState[attribute].split(':'); + + case 'toggle': + return [widgetState[attribute]]; + + default: + return []; + } +} + +function getRefinements( + attribute: string, + searchState: SearchState = {} +): TrackedFilterRefinement[] { + const refinements = Object.keys(searchState) + .filter(widgetKey => Boolean(searchState[widgetKey][attribute])) + .map(widgetKey => getWidgetRefinements(attribute, widgetKey, searchState)) + .reduce((acc, current) => acc.concat(current), []); // flatten the refinements + + return refinements; +} + +function getRuleContextsFromTrackedFilters({ + searchState, + trackedFilters, +}: { + searchState: SearchState; + trackedFilters: QueryRulesProps['trackedFilters']; +}) { + const ruleContexts = Object.keys(trackedFilters).reduce( + (facets, facetName) => { + const facetRefinements: TrackedFilterRefinement[] = getRefinements( + facetName, + searchState + ); + + const getTrackedFacetValues = trackedFilters[facetName]; + const trackedFacetValues = getTrackedFacetValues(facetRefinements); + + return [ + ...facets, + ...facetRefinements + .filter(facetRefinement => + trackedFacetValues.includes(facetRefinement) + ) + .map(facetValue => + escapeRuleContext(`ais-${facetName}-${facetValue}`) + ), + ]; + }, + [] + ); + + return ruleContexts; +} + export default createConnector({ displayName: 'AlgoliaQueryRules', defaultProps: { transformItems: items => items, + transformRuleContexts: ruleContexts => ruleContexts, + trackedFilters: {}, } as QueryRulesProps, getProvidedProps(props: QueryRulesProps, _1: any, searchResults: any) { @@ -35,4 +134,42 @@ export default createConnector({ canRefine: transformedItems.length > 0, }; }, + + getSearchParameters( + searchParameters: SearchParameters, + props: QueryRulesProps, + searchState: SearchState + ) { + if (Object.keys(props.trackedFilters).length === 0) { + return searchParameters; + } + + const indexSearchState = hasMultipleIndices(this.context) + ? searchState.indices[getIndexId(this.context)] + : searchState; + + const newRuleContexts = getRuleContextsFromTrackedFilters({ + searchState: indexSearchState, + trackedFilters: props.trackedFilters, + }); + + const initialRuleContexts = searchParameters.ruleContexts || []; + const nextRuleContexts = [...initialRuleContexts, ...newRuleContexts]; + + if (process.env.NODE_ENV === 'development') { + if (nextRuleContexts.length > 10) { + // tslint:disable-next-line:no-console + console.warn( + `The maximum number of \`ruleContexts\` is 10. They have been sliced to that limit. +Consider using \`transformRuleContexts\` to minimize the number of rules sent to Algolia.` + ); + } + } + + const ruleContexts = props + .transformRuleContexts(nextRuleContexts) + .slice(0, 10); + + return searchParameters.setQueryParameter('ruleContexts', ruleContexts); + }, });