From 922879e4ced413280f45bfbe433d41327e44528e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Chalifour?= Date: Mon, 1 Apr 2019 12:04:49 +0200 Subject: [PATCH] feat(queryRules): add context features to Query Rules (#3617) * feat(queryRules): add context features to connectQueryRules * feat(queryRules): add queryRuleContext widget (#3602) --- .../__tests__/connectQueryRules-test.ts | 523 +++++++++++++++++- .../query-rules/connectQueryRules.ts | 199 ++++++- .../{getRefinements.js => getRefinements.ts} | 61 +- src/widgets/index.js | 3 + .../__tests__/query-rule-context-test.ts | 27 + .../query-rule-context/query-rule-context.tsx | 33 ++ stories/query-rule-context.stories.ts | 116 ++++ 7 files changed, 945 insertions(+), 17 deletions(-) rename src/lib/utils/{getRefinements.js => getRefinements.ts} (67%) create mode 100644 src/widgets/query-rule-context/__tests__/query-rule-context-test.ts create mode 100644 src/widgets/query-rule-context/query-rule-context.tsx create mode 100644 stories/query-rule-context.stories.ts diff --git a/src/connectors/query-rules/__tests__/connectQueryRules-test.ts b/src/connectors/query-rules/__tests__/connectQueryRules-test.ts index d54f20b393..fdb825a76c 100644 --- a/src/connectors/query-rules/__tests__/connectQueryRules-test.ts +++ b/src/connectors/query-rules/__tests__/connectQueryRules-test.ts @@ -41,6 +41,21 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/query-rules connectQueryRules(() => {})({}); }).not.toThrow(); }); + + test('throws with a non-functon tracked filter', () => { + expect(() => { + makeWidget({ + // @ts-ignore + trackedFilters: { + brand: ['Samsung'], + }, + }); + }).toThrowErrorMatchingInlineSnapshot(` +"'The \\"brand\\" filter value in the \`trackedFilters\` option expects a function. + +See documentation: https://www.algolia.com/doc/api-reference/widgets/query-rules/js/#connector" +`); + }); }); describe('lifecycle', () => { @@ -122,7 +137,7 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/query-rules const widget = makeWidget({}); widget.init({ helper, state: helper.state, instantSearchInstance: {} }); - widget.dispose({ state: helper.getState() }); + widget.dispose({ helper, state: helper.getState() }); expect(unmountFn).toHaveBeenCalledTimes(1); }); @@ -155,5 +170,511 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/query-rules expect(items).toEqual({ banner: 'image1.png' }); }); }); + + describe('trackedFilters', () => { + test('adds ruleContexts to search parameters from initial facet and numeric refinements', () => { + const helper = createFakeHelper({ + disjunctiveFacets: ['brand'], + disjunctiveFacetsRefinements: { + brand: ['Samsung', 'Apple'], + }, + numericRefinements: { + price: { + '<=': [500, 400], + '>=': [100], + }, + }, + }); + const brandFilterSpy = jest.fn(values => values); + const priceFilterSpy = jest.fn(values => values); + const widget = makeWidget({ + trackedFilters: { + brand: brandFilterSpy, + price: priceFilterSpy, + }, + }); + + widget.init({ helper, state: helper.state, instantSearchInstance: {} }); + + // Query parameters are initially set in the helper. + // Therefore, `ruleContexts` should be set. + expect(helper.getState().ruleContexts).toEqual([ + 'ais-brand-Samsung', + 'ais-brand-Apple', + 'ais-price-500', + 'ais-price-400', + 'ais-price-100', + ]); + expect(brandFilterSpy).toHaveBeenCalledTimes(1); + expect(brandFilterSpy).toHaveBeenCalledWith(['Samsung', 'Apple']); + expect(priceFilterSpy).toHaveBeenCalledTimes(1); + expect(priceFilterSpy).toHaveBeenCalledWith([500, 400, 100]); + }); + + test('adds ruleContexts to search parameters if transformRuleContexts is provided', () => { + const helper = createFakeHelper({ + disjunctiveFacets: ['brand'], + }); + const brandFilterSpy = jest.fn(values => values); + const widget = makeWidget({ + trackedFilters: { + brand: brandFilterSpy, + }, + transformRuleContexts: () => ['overriden-rule'], + }); + + widget.init({ helper, state: helper.state, instantSearchInstance: {} }); + + expect(helper.getState().ruleContexts).toEqual(['overriden-rule']); + expect(brandFilterSpy).toHaveBeenCalledTimes(1); + expect(brandFilterSpy).toHaveBeenCalledWith([]); + }); + + test('adds all ruleContexts to search parameters from dynamically added facet and numeric refinements', () => { + const helper = createFakeHelper({ + disjunctiveFacets: ['brand'], + }); + const brandFilterSpy = jest.fn(values => values); + const priceFilterSpy = jest.fn(values => values); + const widget = makeWidget({ + trackedFilters: { + brand: brandFilterSpy, + price: priceFilterSpy, + }, + }); + + widget.init({ helper, state: helper.state, instantSearchInstance: {} }); + + // There's no results yet, so no `ruleContexts` should be set. + expect(helper.getState().ruleContexts).toEqual(undefined); + expect(brandFilterSpy).toHaveBeenCalledTimes(0); + expect(priceFilterSpy).toHaveBeenCalledTimes(0); + + widget.render({ + helper, + results: new SearchResults(helper.getState(), [ + {}, + { + facets: { + brand: { + Samsung: 100, + Apple: 100, + }, + }, + }, + ]), + instantSearchInstance: {}, + }); + + // There are some results with the facets that we track in the + // widget but the query parameters are not set in the helper. + // Therefore, no `ruleContexts` should be set. + expect(helper.getState().ruleContexts).toEqual(undefined); + expect(brandFilterSpy).toHaveBeenCalledTimes(0); + expect(priceFilterSpy).toHaveBeenCalledTimes(0); + + // Updating the helper state in a single method call + // calls the tracked filters only once. + helper.setState({ + disjunctiveFacetsRefinements: { + brand: ['Samsung', 'Apple'], + }, + numericRefinements: { + price: { + '<=': [500, 400], + '>=': [100], + }, + }, + }); + + widget.render({ + helper, + results: new SearchResults(helper.getState(), [ + {}, + { + facets: { + brand: { + Samsung: 100, + Apple: 100, + }, + }, + }, + ]), + instantSearchInstance: {}, + }); + + // The search state contains the facets that we track, + // therefore the `ruleContexts` should finally be set. + expect(helper.getState().ruleContexts).toEqual([ + 'ais-brand-Samsung', + 'ais-brand-Apple', + 'ais-price-500', + 'ais-price-400', + 'ais-price-100', + ]); + expect(brandFilterSpy).toHaveBeenCalledTimes(1); + expect(brandFilterSpy).toHaveBeenCalledWith(['Samsung', 'Apple']); + expect(priceFilterSpy).toHaveBeenCalledTimes(1); + expect(priceFilterSpy).toHaveBeenCalledWith([500, 400, 100]); + }); + + test('can filter trackedFilters with facets refinements', () => { + const helper = createFakeHelper({ + disjunctiveFacets: ['brand'], + }); + const brandFilterSpy = jest.fn(() => ['Samsung']); + const widget = makeWidget({ + trackedFilters: { + brand: brandFilterSpy, + }, + }); + + widget.init({ helper, state: helper.state, instantSearchInstance: {} }); + + expect(helper.getState().ruleContexts).toEqual(undefined); + expect(brandFilterSpy).toHaveBeenCalledTimes(0); + + widget.render({ + helper, + results: new SearchResults(helper.getState(), [ + {}, + { + facets: { + brand: { + Samsung: 100, + Apple: 100, + }, + }, + }, + ]), + instantSearchInstance: {}, + }); + + expect(helper.getState().ruleContexts).toEqual(undefined); + expect(brandFilterSpy).toHaveBeenCalledTimes(0); + + helper.setState({ + disjunctiveFacetsRefinements: { + brand: ['Samsung', 'Apple'], + }, + }); + + widget.render({ + helper, + results: new SearchResults(helper.getState(), [ + {}, + { + facets: { + brand: { + Samsung: 100, + Apple: 100, + }, + }, + }, + ]), + instantSearchInstance: {}, + }); + + expect(helper.getState().ruleContexts).toEqual(['ais-brand-Samsung']); + expect(brandFilterSpy).toHaveBeenCalledTimes(1); + expect(brandFilterSpy).toHaveBeenCalledWith(['Samsung', 'Apple']); + }); + + test('can filter tracked filters from numeric refinements', () => { + const helper = createFakeHelper(); + const priceFilterSpy = jest.fn(() => [500]); + const widget = makeWidget({ + trackedFilters: { + price: priceFilterSpy, + }, + }); + + widget.init({ helper, state: helper.state, instantSearchInstance: {} }); + + expect(helper.getState().ruleContexts).toEqual(undefined); + expect(priceFilterSpy).toHaveBeenCalledTimes(0); + + widget.render({ + helper, + results: new SearchResults(helper.getState(), [{}, {}]), + instantSearchInstance: {}, + }); + + expect(helper.getState().ruleContexts).toEqual(undefined); + expect(priceFilterSpy).toHaveBeenCalledTimes(0); + + helper.setState({ + numericRefinements: { + price: { + '<=': [500, 400], + '>=': [100], + }, + }, + }); + + widget.render({ + helper, + results: new SearchResults(helper.getState(), [{}, {}]), + instantSearchInstance: {}, + }); + + expect(helper.getState().ruleContexts).toEqual(['ais-price-500']); + expect(priceFilterSpy).toHaveBeenCalledTimes(1); + expect(priceFilterSpy).toHaveBeenCalledWith([500, 400, 100]); + }); + + test('escapes all ruleContexts before passing them to search parameters', () => { + const helper = createFakeHelper({ + disjunctiveFacets: ['brand'], + }); + const widget = makeWidget({ + trackedFilters: { + brand: values => values, + }, + }); + + widget.init({ helper, state: helper.state, instantSearchInstance: {} }); + + helper.setState({ + disjunctiveFacetsRefinements: { + brand: ['Insignia™', '© Apple'], + }, + }); + + widget.render({ + helper, + results: new SearchResults(helper.getState(), [ + {}, + { + facets: { + brand: { + 'Insignia™': 100, + '© Apple': 100, + }, + }, + }, + ]), + instantSearchInstance: {}, + }); + + expect(helper.getState().ruleContexts).toEqual([ + 'ais-brand-Insignia_', + 'ais-brand-_Apple', + ]); + }); + + test('slices and warns when more than 10 ruleContexts are applied', () => { + const brandFacetRefinements = [ + 'Insignia', + 'Canon', + 'Dynex', + 'LG', + 'Metra', + 'Sony', + 'HP', + 'Apple', + 'Samsung', + 'Speck', + 'PNY', + ]; + + expect(brandFacetRefinements).toHaveLength(11); + + const helper = createFakeHelper({ + disjunctiveFacets: ['brand'], + }); + const widget = makeWidget({ + trackedFilters: { + brand: values => values, + }, + }); + + widget.init({ + helper, + state: helper.state, + instantSearchInstance: {}, + }); + + expect(() => { + helper.setState({ + disjunctiveFacetsRefinements: { + brand: brandFacetRefinements, + }, + }); + }) + .toWarnDev(`[InstantSearch.js]: 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.`); + + widget.render({ + helper, + results: new SearchResults(helper.getState(), [ + {}, + { + facets: { + brand: { + Insignia: 100, + Canon: 100, + Dynex: 100, + LG: 100, + Metra: 100, + Sony: 100, + HP: 100, + Apple: 100, + Samsung: 100, + Speck: 100, + PNY: 100, + }, + }, + }, + ]), + instantSearchInstance: {}, + }); + + expect(helper.getState().ruleContexts).toHaveLength(10); + expect(helper.getState().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', + ]); + }); + + test('reinitializes the rule contexts on dispose', () => { + const helper = createFakeHelper({ + disjunctiveFacets: ['brand'], + ruleContexts: ['initial-rule'], + }); + const widget = makeWidget({ + trackedFilters: { + brand: values => values, + }, + }); + + widget.init({ helper, state: helper.state, instantSearchInstance: {} }); + + helper.setState({ + disjunctiveFacetsRefinements: { + brand: ['Samsung', 'Apple'], + }, + }); + + widget.render({ + helper, + results: new SearchResults(helper.getState(), [ + {}, + { + facets: { + brand: { + Samsung: 100, + Apple: 100, + }, + }, + }, + ]), + instantSearchInstance: {}, + }); + + expect(helper.getState().ruleContexts).toEqual([ + 'initial-rule', + 'ais-brand-Samsung', + 'ais-brand-Apple', + ]); + + const nextState = widget.dispose({ helper, state: helper.getState() }); + + expect(nextState.ruleContexts).toEqual(['initial-rule']); + }); + + test('stops tracking filters after dispose', () => { + const helper = createFakeHelper({ + disjunctiveFacets: ['brand'], + disjunctiveFacetsRefinements: { + brand: ['Samsung'], + }, + }); + const brandFilterSpy = jest.fn(values => values); + const widget = makeWidget({ + trackedFilters: { + brand: brandFilterSpy, + }, + }); + + expect(helper.getState().ruleContexts).toEqual(undefined); + + widget.init({ helper, state: helper.state, instantSearchInstance: {} }); + + expect(helper.getState().ruleContexts).toEqual(['ais-brand-Samsung']); + expect(brandFilterSpy).toHaveBeenCalledTimes(1); + expect(brandFilterSpy).toHaveBeenCalledWith(['Samsung']); + + widget.dispose({ helper, state: helper.state }); + + helper.setState({ + disjunctiveFacetsRefinements: { + brand: ['Samsung', 'Apple'], + }, + }); + + expect(helper.getState().ruleContexts).toEqual(undefined); + expect(brandFilterSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('transformRuleContexts', () => { + test('transform all rule contexts before passing them to search parameters, except the initial ones', () => { + const helper = createFakeHelper({ + disjunctiveFacets: ['brand'], + disjunctiveFacetsRefinements: { + brand: ['Samsung', 'Apple'], + }, + ruleContexts: ['initial-rule'], + }); + const transformRuleContextsSpy = jest.fn((rules: string[]) => + rules.map(rule => rule.replace('ais-', 'transformed-')) + ); + const widget = makeWidget({ + trackedFilters: { + brand: values => values, + }, + transformRuleContexts: transformRuleContextsSpy, + }); + + widget.init({ helper, state: helper.state, instantSearchInstance: {} }); + + widget.render({ + helper, + results: new SearchResults(helper.getState(), [ + {}, + { + facets: { + brand: { + Samsung: 100, + Apple: 100, + }, + }, + }, + ]), + instantSearchInstance: {}, + }); + + expect(transformRuleContextsSpy).toHaveBeenCalledTimes(1); + expect(transformRuleContextsSpy).toHaveBeenCalledWith([ + 'initial-rule', + 'ais-brand-Samsung', + 'ais-brand-Apple', + ]); + expect(helper.getState().ruleContexts).toEqual([ + 'initial-rule', + 'transformed-brand-Samsung', + 'transformed-brand-Apple', + ]); + }); + }); }); }); diff --git a/src/connectors/query-rules/connectQueryRules.ts b/src/connectors/query-rules/connectQueryRules.ts index 45bca979cc..74bded0509 100644 --- a/src/connectors/query-rules/connectQueryRules.ts +++ b/src/connectors/query-rules/connectQueryRules.ts @@ -1,18 +1,34 @@ import noop from 'lodash/noop'; -import { Renderer, RenderOptions, WidgetFactory } from '../../types'; +import isEqual from 'lodash/isEqual'; +import { + Renderer, + RenderOptions, + WidgetFactory, + Helper, + HelperState, +} from '../../types'; import { checkRendering, createDocumentationMessageGenerator, + warning, + getRefinements, } from '../../lib/utils'; +import { + Refinement as InternalRefinement, + NumericRefinement as InternalNumericRefinement, +} from '../../lib/utils/getRefinements'; -const withUsage = createDocumentationMessageGenerator({ - name: 'query-rules', - connector: true, -}); - +export type ParamTrackedFilters = { + [facetName: string]: ( + facetValues: Array + ) => Array; +}; +export type ParamTransformRuleContexts = (ruleContexts: string[]) => string[]; type ParamTransformItems = (items: object[]) => any; export type QueryRulesConnectorParams = { + trackedFilters?: ParamTrackedFilters; + transformRuleContexts?: ParamTransformRuleContexts; transformItems?: ParamTransformItems; }; @@ -33,15 +49,172 @@ export type QueryRulesConnector = ( unmount?: () => void ) => QueryRulesWidgetFactory; +const withUsage = createDocumentationMessageGenerator({ + name: 'query-rules', + connector: true, +}); + +function hasStateRefinements({ + disjunctiveFacetsRefinements, + facetsRefinements, + hierarchicalFacetsRefinements, + numericRefinements, +}) { + return [ + disjunctiveFacetsRefinements, + facetsRefinements, + hierarchicalFacetsRefinements, + numericRefinements, + ].some(refinement => Object.keys(refinement).length > 0); +} + +// 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) { + return ruleName.replace(/[^a-z0-9-_]+/gi, '_'); +} + +function getRuleContextsFromTrackedFilters({ + helper, + sharedHelperState, + trackedFilters, +}: { + helper: Helper; + sharedHelperState: HelperState; + trackedFilters: ParamTrackedFilters; +}) { + const ruleContexts = Object.keys(trackedFilters).reduce( + (facets, facetName) => { + const facetRefinements: Array = getRefinements( + helper.lastResults || {}, + sharedHelperState + ) + .filter( + (refinement: InternalRefinement) => + refinement.attributeName === facetName + ) + .map( + (refinement: InternalRefinement) => + (refinement as InternalNumericRefinement).numericValue || + refinement.name + ); + + const getTrackedFacetValues = trackedFilters[facetName]; + const trackedFacetValues = getTrackedFacetValues(facetRefinements); + + return [ + ...facets, + ...facetRefinements + .filter(facetRefinement => + trackedFacetValues.includes(facetRefinement) + ) + .map(facetValue => + escapeRuleContext(`ais-${facetName}-${facetValue}`) + ), + ]; + }, + [] + ); + + return ruleContexts; +} + +function applyRuleContexts( + this: { + helper: Helper; + initialRuleContexts: string[]; + trackedFilters: ParamTrackedFilters; + transformRuleContexts: ParamTransformRuleContexts; + }, + sharedHelperState: HelperState +) { + const { + helper, + initialRuleContexts, + trackedFilters, + transformRuleContexts, + } = this; + + const previousRuleContexts: string[] = sharedHelperState.ruleContexts || []; + const newRuleContexts = getRuleContextsFromTrackedFilters({ + helper, + sharedHelperState, + trackedFilters, + }); + const nextRuleContexts = [...initialRuleContexts, ...newRuleContexts]; + + warning( + nextRuleContexts.length <= 10, + ` +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 = transformRuleContexts(nextRuleContexts).slice(0, 10); + + if (!isEqual(previousRuleContexts, ruleContexts)) { + helper.overrideStateWithoutTriggeringChangeEvent({ + ...sharedHelperState, + ruleContexts, + }); + } +} + const connectQueryRules: QueryRulesConnector = (render, unmount = noop) => { checkRendering(render, withUsage()); return widgetParams => { - const { transformItems = (items => items) as ParamTransformItems } = - widgetParams || {}; + const { + trackedFilters = {} as ParamTrackedFilters, + transformRuleContexts = (rules => rules) as ParamTransformRuleContexts, + transformItems = (items => items) as ParamTransformItems, + } = widgetParams || {}; + + Object.keys(trackedFilters).forEach(facetName => { + if (typeof trackedFilters[facetName] !== 'function') { + throw new Error( + withUsage( + `'The "${facetName}" filter value in the \`trackedFilters\` option expects a function.` + ) + ); + } + }); + + const hasTrackedFilters = Object.keys(trackedFilters).length > 0; + + // We store the initial rule contexts applied before creating the widget + // so that we do not override them with the rules created from `trackedFilters`. + let initialRuleContexts: string[] = []; + let onHelperChange: (state: HelperState) => void; return { - init({ instantSearchInstance }) { + init({ helper, state, instantSearchInstance }) { + initialRuleContexts = state.ruleContexts || []; + onHelperChange = applyRuleContexts.bind({ + helper, + initialRuleContexts, + trackedFilters, + transformRuleContexts, + }); + + if (hasTrackedFilters) { + // We need to apply the `ruleContexts` based on the `trackedFilters` + // before the helper changes state in some cases: + // - Some filters are applied on the first load (e.g. using `configure`) + // - The `transformRuleContexts` option sets initial `ruleContexts`. + if ( + hasStateRefinements(state) || + Boolean(widgetParams.transformRuleContexts) + ) { + onHelperChange(state); + } + + // We track every change in the helper to override its state and add + // any `ruleContexts` needed based on the `trackedFilters`. + helper.on('change', onHelperChange); + } + render( { items: [], @@ -66,9 +239,15 @@ const connectQueryRules: QueryRulesConnector = (render, unmount = noop) => { ); }, - dispose({ state }) { + dispose({ helper, state }) { unmount(); + if (hasTrackedFilters) { + helper.removeListener('change', onHelperChange); + + return state.setQueryParameter('ruleContexts', initialRuleContexts); + } + return state; }, }; diff --git a/src/lib/utils/getRefinements.js b/src/lib/utils/getRefinements.ts similarity index 67% rename from src/lib/utils/getRefinements.js rename to src/lib/utils/getRefinements.ts index 147a7f9022..ff16ae68ac 100644 --- a/src/lib/utils/getRefinements.js +++ b/src/lib/utils/getRefinements.ts @@ -1,12 +1,57 @@ import find from 'lodash/find'; import get from 'lodash/get'; import forEach from 'lodash/forEach'; +import { HelperState, SearchResults } from '../../types'; import unescapeRefinement from './unescapeRefinement'; -function getRefinement(state, type, attributeName, name, resultsFacets) { - const res = { type, attributeName, name }; - let facet = find(resultsFacets, { name: attributeName }); - let count; +export interface FacetRefinement { + type: + | 'facet' + | 'exclude' + | 'disjunctive' + | 'hierarchical' + | 'numeric' + | 'tag' + | 'query'; + attributeName: string; + name: string; + count?: number; + exhaustive?: boolean; +} + +export interface QueryRefinement + extends Pick { + type: 'query'; + query: string; +} + +export interface NumericRefinement extends FacetRefinement { + type: 'numeric'; + numericValue: number; + operator: '<' | '<=' | '=' | '>=' | '>'; +} + +export interface FacetExcludeRefinement extends FacetRefinement { + type: 'exclude'; + exclude: boolean; +} + +export type Refinement = + | FacetRefinement + | QueryRefinement + | NumericRefinement + | FacetExcludeRefinement; + +function getRefinement( + state: HelperState, + type: Refinement['type'], + attributeName: Refinement['attributeName'], + name: Refinement['name'], + resultsFacets: string[] +): Refinement { + const res: Refinement = { type, attributeName, name }; + let facet: any = find(resultsFacets, { name: attributeName }); + let count: number; if (type === 'hierarchical') { const facetDeclaration = state.getHierarchicalFacetByName(attributeName); @@ -34,8 +79,12 @@ function getRefinement(state, type, attributeName, name, resultsFacets) { return res; } -function getRefinements(results, state, clearsQuery) { - const res = []; +function getRefinements( + results: SearchResults, + state: HelperState, + clearsQuery: boolean = false +): Refinement[] { + const res: Refinement[] = []; forEach(state.facetsRefinements, (refinements, attributeName) => { forEach(refinements, name => { diff --git a/src/widgets/index.js b/src/widgets/index.js index 82fd7531b1..b04b03514f 100644 --- a/src/widgets/index.js +++ b/src/widgets/index.js @@ -40,3 +40,6 @@ export { default as panel } from './panel/panel'; export { default as queryRuleCustomData, } from './query-rule-custom-data/query-rule-custom-data'; +export { + default as queryRuleContext, +} from './query-rule-context/query-rule-context'; diff --git a/src/widgets/query-rule-context/__tests__/query-rule-context-test.ts b/src/widgets/query-rule-context/__tests__/query-rule-context-test.ts new file mode 100644 index 0000000000..485adcc1cb --- /dev/null +++ b/src/widgets/query-rule-context/__tests__/query-rule-context-test.ts @@ -0,0 +1,27 @@ +import queryRuleContext from '../query-rule-context'; + +describe('queryRuleContext', () => { + describe('Usage', () => { + test('throws trackedFilters error without options', () => { + expect(() => { + // @ts-ignore + queryRuleContext(); + }).toThrowErrorMatchingInlineSnapshot(` +"The \`trackedFilters\` option is required. + +See documentation: https://www.algolia.com/doc/api-reference/widgets/query-rule-context/js/" +`); + }); + + test('throws trackedFilters error with empty options', () => { + expect(() => { + // @ts-ignore + queryRuleContext({}); + }).toThrowErrorMatchingInlineSnapshot(` +"The \`trackedFilters\` option is required. + +See documentation: https://www.algolia.com/doc/api-reference/widgets/query-rule-context/js/" +`); + }); + }); +}); diff --git a/src/widgets/query-rule-context/query-rule-context.tsx b/src/widgets/query-rule-context/query-rule-context.tsx new file mode 100644 index 0000000000..cd7262e9c2 --- /dev/null +++ b/src/widgets/query-rule-context/query-rule-context.tsx @@ -0,0 +1,33 @@ +import noop from 'lodash/noop'; +import { WidgetFactory } from '../../types'; +import { createDocumentationMessageGenerator } from '../../lib/utils'; +import connectQueryRules, { + ParamTrackedFilters, + ParamTransformRuleContexts, +} from '../../connectors/query-rules/connectQueryRules'; + +type QueryRulesWidgetParams = { + trackedFilters: ParamTrackedFilters; + transformRuleContexts?: ParamTransformRuleContexts; +}; + +type QueryRuleContext = WidgetFactory; + +const withUsage = createDocumentationMessageGenerator({ + name: 'query-rule-context', +}); + +const queryRuleContext: QueryRuleContext = ( + { trackedFilters, transformRuleContexts } = {} as QueryRulesWidgetParams +) => { + if (!trackedFilters) { + throw new Error(withUsage('The `trackedFilters` option is required.')); + } + + return connectQueryRules(noop)({ + trackedFilters, + transformRuleContexts, + }); +}; + +export default queryRuleContext; diff --git a/stories/query-rule-context.stories.ts b/stories/query-rule-context.stories.ts new file mode 100644 index 0000000000..24dd6738d0 --- /dev/null +++ b/stories/query-rule-context.stories.ts @@ -0,0 +1,116 @@ +import { storiesOf } from '@storybook/html'; +import { withHits } from '../.storybook/decorators'; +import moviesPlayground from '../.storybook/playgrounds/movies'; + +type CustomDataItem = { + title: string; + banner: string; + link: string; +}; + +const searchOptions = { + appId: 'latency', + apiKey: 'af044fb0788d6bb15f807e4420592bc5', + indexName: 'instant_search_movies', + playground: moviesPlayground, +}; + +storiesOf('QueryRuleContext', module) + .add( + 'default', + withHits(({ search, container, instantsearch }) => { + const widgetContainer = document.createElement('div'); + const description = document.createElement('ul'); + description.innerHTML = ` +
  • On empty query, select the "Drama" category and The Shawshank Redemption appears
  • +
  • On empty query, select the "Thriller" category and Pulp Fiction appears
  • +
  • Type music and a banner will appear.
  • + `; + + container.appendChild(description); + container.appendChild(widgetContainer); + + search.addWidget( + instantsearch.widgets.queryRuleContext({ + trackedFilters: { + genre: () => ['Thriller', 'Drama'], + }, + }) + ); + + search.addWidget( + instantsearch.widgets.queryRuleCustomData({ + container: widgetContainer, + transformItems: (items: CustomDataItem[]) => items[0], + templates: { + default({ title, banner, link }: CustomDataItem) { + if (!banner) { + return ''; + } + + return ` +

    ${title}

    + + + ${title} + + `; + }, + }, + }) + ); + }, searchOptions) + ) + .add( + 'with initial filter', + withHits(({ search, container, instantsearch }) => { + const widgetContainer = document.createElement('div'); + const description = document.createElement('ul'); + description.innerHTML = ` +
  • Select the "Drama" category and The Shawshank Redemption appears
  • +
  • Select the "Thriller" category and Pulp Fiction appears
  • +
  • Type music and a banner will appear.
  • + `; + + container.appendChild(description); + container.appendChild(widgetContainer); + + search.addWidget( + instantsearch.widgets.configure({ + disjunctiveFacetsRefinements: { + genre: ['Drama'], + }, + }) + ); + + search.addWidget( + instantsearch.widgets.queryRuleContext({ + trackedFilters: { + genre: () => ['Thriller', 'Drama'], + }, + }) + ); + + search.addWidget( + instantsearch.widgets.queryRuleCustomData({ + container: widgetContainer, + transformItems: (items: CustomDataItem[]) => items[0], + templates: { + default({ title, banner, link }: CustomDataItem) { + if (!banner) { + return ''; + } + + return ` +

    ${title}

    + + + ${title} + + `; + }, + }, + }) + ); + }, searchOptions) + );