diff --git a/package.json b/package.json index 810fc43368..29b369cfd0 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.50 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..b29d5d1efe 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,368 @@ 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 availableInStockSpy = jest.fn(values => values); + const props: QueryRulesProps = { + ...defaultProps, + trackedFilters: { + freeShipping: freeShippingSpy, + availableInStock: availableInStockSpy, + }, + }; + const searchState = { + toggle: { + freeShipping: true, + availableInStock: false, + }, + }; + const searchParameters = getSearchParameters( + new SearchParameters(), + props, + searchState + ); + + expect(freeShippingSpy).toHaveBeenCalledTimes(1); + expect(freeShippingSpy).toHaveBeenCalledWith([true]); + expect(availableInStockSpy).toHaveBeenCalledTimes(1); + expect(availableInStockSpy).toHaveBeenCalledWith([false]); + expect(searchParameters.ruleContexts).toEqual([ + 'ais-freeShipping-true', + 'ais-availableInStock-false', + ]); + }); + + 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', () => { + // We need to simulate being in development mode and to mock the global console object + // in this test to assert that development warnings are displayed correctly. + const originalNodeEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'development'; + const warnSpy = jest + .spyOn(console, 'warn') + .mockImplementation(() => {}); + + 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 + ); + + 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', + ]); + + process.env.NODE_ENV = originalNodeEnv; + warnSpy.mockRestore(); + }); + }); + + 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 +450,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 +509,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..4baa4f5152 100644 --- a/packages/react-instantsearch-core/src/connectors/connectQueryRules.ts +++ b/packages/react-instantsearch-core/src/connectors/connectQueryRules.ts @@ -1,19 +1,120 @@ 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 => typeof searchState[widgetKey][attribute] !== 'undefined' + ) + .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 +136,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); + }, }); diff --git a/packages/react-instantsearch-core/src/index.js b/packages/react-instantsearch-core/src/index.js index 4adc87b22c..d2553186ea 100644 --- a/packages/react-instantsearch-core/src/index.js +++ b/packages/react-instantsearch-core/src/index.js @@ -10,6 +10,7 @@ export { default as translatable } from './core/translatable'; // Widgets export { default as Configure } from './widgets/Configure'; +export { default as QueryRuleContext } from './widgets/QueryRuleContext'; // Connectors export { diff --git a/packages/react-instantsearch-core/src/widgets/QueryRuleContext.ts b/packages/react-instantsearch-core/src/widgets/QueryRuleContext.ts new file mode 100644 index 0000000000..32bb0e057d --- /dev/null +++ b/packages/react-instantsearch-core/src/widgets/QueryRuleContext.ts @@ -0,0 +1,5 @@ +import connectQueryRules from '../connectors/connectQueryRules'; + +export default connectQueryRules(function QueryRuleContext() { + return null; +}); diff --git a/packages/react-instantsearch-dom/src/index.js b/packages/react-instantsearch-dom/src/index.js index c5f7f9f111..bd3ebd07db 100644 --- a/packages/react-instantsearch-dom/src/index.js +++ b/packages/react-instantsearch-dom/src/index.js @@ -5,6 +5,7 @@ export { translatable } from 'react-instantsearch-core'; // Widget export { Configure } from 'react-instantsearch-core'; +export { QueryRuleContext } from 'react-instantsearch-core'; // Connectors export { connectAutoComplete } from 'react-instantsearch-core'; diff --git a/packages/react-instantsearch-native/src/index.js b/packages/react-instantsearch-native/src/index.js index f190a2d87b..760c693355 100644 --- a/packages/react-instantsearch-native/src/index.js +++ b/packages/react-instantsearch-native/src/index.js @@ -5,6 +5,7 @@ export { translatable } from 'react-instantsearch-core'; // Widget export { Configure } from 'react-instantsearch-core'; +export { QueryRuleContext } from 'react-instantsearch-core'; // Connectors export { connectAutoComplete } from 'react-instantsearch-core'; diff --git a/stories/QueryRuleContext.stories.tsx b/stories/QueryRuleContext.stories.tsx new file mode 100644 index 0000000000..894946cb24 --- /dev/null +++ b/stories/QueryRuleContext.stories.tsx @@ -0,0 +1,162 @@ +import React from 'react'; +import { storiesOf } from '@storybook/react'; +import { connectHits } from 'react-instantsearch-core'; +import { + QueryRuleCustomData, + QueryRuleContext, + Highlight, + RefinementList, +} from 'react-instantsearch-dom'; +import { WrapWithHits } from './util'; + +type CustomDataItem = { + title: string; + banner: string; + link: string; +}; + +type MovieHit = { + actors: string[]; + color: string; + genre: string[]; + image: string; + objectID: string; + score: number; + title: string; +}; + +const stories = storiesOf('QueryRuleContext', module); + +const StoryHits = connectHits(({ hits }: { hits: MovieHit[] }) => ( +
+ {hits.map(hit => ( +
+
+ +
+ +
+
+ +
+
+
+ ))} +
+)); + +const storyProps = { + appId: 'latency', + apiKey: 'af044fb0788d6bb15f807e4420592bc5', + indexName: 'instant_search_movies', + linkedStoryGroup: 'QueryRuleCustomData', + hitsElement: , +}; + +stories + .add('default', () => ( + +
    +
  • + 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 +
  • +
+ +
+ + +
+ ['Drama', 'Thriller'], + }} + /> + + + {({ items }: { items: CustomDataItem[] }) => + items.map(({ banner, title, link }) => { + if (!banner) { + return null; + } + + return ( +
+

{title}

+ + + {title} + +
+ ); + }) + } +
+
+
+
+ )) + .add('with default rule context', () => ( + +
    +
  • The rule context `ais-genre-Drama` is applied by default
  • +
  • + Select the "Drama" category and The Shawshank Redemption appears +
  • +
  • Select the "Thriller" category and Pulp Fiction appears
  • +
  • + Type music and a banner will appear +
  • +
+ +
+ + +
+ ['Drama', 'Thriller'], + }} + transformRuleContexts={(ruleContexts: string[]) => { + if (ruleContexts.length === 0) { + return ['ais-genre-Drama']; + } + + return ruleContexts; + }} + /> + + + {({ items }: { items: CustomDataItem[] }) => + items.map(({ banner, title, link }) => { + if (!banner) { + return null; + } + + return ( +
+

{title}

+ + + {title} + +
+ ); + }) + } +
+
+
+
+ )); diff --git a/storybook/public/default.css b/storybook/public/default.css index b45af2521f..7713b5444d 100644 --- a/storybook/public/default.css +++ b/storybook/public/default.css @@ -8,3 +8,7 @@ a { color: #3e82f7; text-decoration: none; } + +#root img { + max-width: 100%; +} diff --git a/tslint.json b/tslint.json index dfa76078f0..3445fed62a 100644 --- a/tslint.json +++ b/tslint.json @@ -11,6 +11,7 @@ "object-literal-sort-keys": false, "ordered-imports": false, "prettier": true, - "jsx-no-lambda": false + "jsx-no-lambda": false, + "no-empty": false } }