diff --git a/packages/react-instantsearch/src/components/List.js b/packages/react-instantsearch/src/components/List.js index 7b715e8c1a..4f290d1fa9 100644 --- a/packages/react-instantsearch/src/components/List.js +++ b/packages/react-instantsearch/src/components/List.js @@ -64,6 +64,7 @@ class List extends Component { {...this.props.cx( 'item', item.isRefined && 'itemSelected', + item.noRefinement && 'itemNoRefinement', items && 'itemParent', items && item.isRefined && 'itemSelectedParent' )} diff --git a/packages/react-instantsearch/src/components/MultiRange.js b/packages/react-instantsearch/src/components/MultiRange.js index 88b6cbd54e..ace5e6adc0 100644 --- a/packages/react-instantsearch/src/components/MultiRange.js +++ b/packages/react-instantsearch/src/components/MultiRange.js @@ -9,6 +9,8 @@ class MultiRange extends Component { items: PropTypes.arrayOf(PropTypes.shape({ label: PropTypes.node.isRequired, value: PropTypes.string.isRequired, + isRefined: PropTypes.bool.isRequired, + noRefinement: PropTypes.bool.isRequired, })).isRequired, refine: PropTypes.func.isRequired, transformItems: PropTypes.func, diff --git a/packages/react-instantsearch/src/components/MultiRange.test.js b/packages/react-instantsearch/src/components/MultiRange.test.js index 807bcc9fd9..7af5094629 100644 --- a/packages/react-instantsearch/src/components/MultiRange.test.js +++ b/packages/react-instantsearch/src/components/MultiRange.test.js @@ -13,10 +13,10 @@ describe('MultiRange', () => { createURL={() => '#'} refine={() => null} items={[ - {label: 'label1', value: '10:', isRefined: false}, - {label: 'label2', value: '10:20', isRefined: false}, - {label: 'label3', value: '20:30', isRefined: false}, - {label: 'label4', value: '30:', isRefined: false}, + {label: 'label1', value: '10:', isRefined: false, noRefinement: false}, + {label: 'label2', value: '10:20', isRefined: false, noRefinement: false}, + {label: 'label3', value: '20:30', isRefined: false, noRefinement: false}, + {label: 'label4', value: '30:', isRefined: false, noRefinement: false}, ]} canRefine={true} /> @@ -30,10 +30,10 @@ describe('MultiRange', () => { createURL={() => '#'} refine={() => null} items={[ - {label: 'label1', value: '10:', isRefined: false}, - {label: 'label2', value: '10:20', isRefined: true}, - {label: 'label3', value: '20:30', isRefined: false}, - {label: 'label4', value: '30:', isRefined: false}, + {label: 'label1', value: '10:', isRefined: false, noRefinement: false}, + {label: 'label2', value: '10:20', isRefined: true, noRefinement: false}, + {label: 'label3', value: '20:30', isRefined: false, noRefinement: false}, + {label: 'label4', value: '30:', isRefined: false, noRefinement: false}, ]} canRefine={true} /> @@ -47,10 +47,10 @@ describe('MultiRange', () => { @@ -69,4 +69,28 @@ describe('MultiRange', () => { wrapper.unmount(); }); + + it('indicate when there is no refinement', () => { + const refine = jest.fn(); + const wrapper = mount( + + ); + + const itemWrapper = wrapper.find('.ais-MultiRange__noRefinement'); + expect(itemWrapper.length).toBe(1); + + const items = wrapper.find('.ais-MultiRange__itemNoRefinement'); + expect(items.length).toBe(4); + + wrapper.unmount(); + }); }); diff --git a/packages/react-instantsearch/src/connectors/connectMultiRange.js b/packages/react-instantsearch/src/connectors/connectMultiRange.js index 037e265160..688269fd82 100644 --- a/packages/react-instantsearch/src/connectors/connectMultiRange.js +++ b/packages/react-instantsearch/src/connectors/connectMultiRange.js @@ -38,6 +38,25 @@ function getCurrentRefinement(props, searchState) { return ''; } +function isRefinementsRangeIncludesInsideItemRange(stats, start, end) { + return stats.min > start && stats.min < end || stats.max > start && stats.max < end; +} + +function isItemRangeIncludedInsideRefinementsRange(stats, start, end) { + return start > stats.min && start < stats.max || end > stats.min && end < stats.max; +} + +function itemHasRefinement(attributeName, results, value) { + const stats = results.getFacetByName(attributeName) ? + results.getFacetStats(attributeName) : null; + const range = value.split(':'); + const start = Number(range[0]) === 0 || value === '' ? Number.NEGATIVE_INFINITY : Number(range[0]); + const end = Number(range[1]) === 0 || value === '' ? Number.POSITIVE_INFINITY : Number(range[1]); + return !(Boolean(stats) && + (isRefinementsRangeIncludesInsideItemRange(stats, start, end) + || isItemRangeIncludedInsideRefinementsRange(stats, start, end))); +} + /** * connectMultiRange connector provides the logic to build a widget that will * give the user the ability to select a range value for a numeric attribute. @@ -51,7 +70,7 @@ function getCurrentRefinement(props, searchState) { * @providedPropType {function} refine - a function to select a range. * @providedPropType {function} createURL - a function to generate a URL for the corresponding search state * @providedPropType {string} currentRefinement - the refinement currently applied. follow the shape of a `string` with a pattern of `'{start}:{end}'` which corresponds to the current selected item. For instance, when the selected item is `{start: 10, end: 20}`, the searchState of the widget is `'10:20'`. When `start` isn't defined, the searchState of the widget is `':{end}'`, and the same way around when `end` isn't defined. However, when neither `start` nor `end` are defined, the searchState is an empty string. - * @providedPropType {array.<{isRefined: boolean, label: string, value: string}>} items - the list of ranges the MultiRange can display. + * @providedPropType {array.<{isRefined: boolean, label: string, value: string, isRefined: boolean, noRefinement: boolean}>} items - the list of ranges the MultiRange can display. */ export default createConnector({ displayName: 'AlgoliaMultiRange', @@ -67,7 +86,7 @@ export default createConnector({ transformItems: PropTypes.func, }, - getProvidedProps(props, searchState) { + getProvidedProps(props, searchState, searchResults) { const currentRefinement = getCurrentRefinement(props, searchState); const items = props.items.map(item => { const value = stringifyItem(item); @@ -75,12 +94,15 @@ export default createConnector({ label: item.label, value, isRefined: value === currentRefinement, + noRefinement: searchResults && searchResults.results ? + itemHasRefinement(getId(props), searchResults.results, value) : false, }; }); + return { items: props.transformItems ? props.transformItems(items) : items, currentRefinement, - canRefine: items.length > 0, + canRefine: !items.reduce((noRefinement, item) => noRefinement && item.noRefinement, true), }; }, @@ -102,6 +124,7 @@ export default createConnector({ getSearchParameters(searchParameters, props, searchState) { const {attributeName} = props; const {start, end} = parseItem(getCurrentRefinement(props, searchState)); + searchParameters = searchParameters.addDisjunctiveFacet(attributeName); if (start) { searchParameters = searchParameters.addNumericRefinement( diff --git a/packages/react-instantsearch/src/connectors/connectMultiRange.test.js b/packages/react-instantsearch/src/connectors/connectMultiRange.test.js index 99aff3f340..fd54d98659 100644 --- a/packages/react-instantsearch/src/connectors/connectMultiRange.test.js +++ b/packages/react-instantsearch/src/connectors/connectMultiRange.test.js @@ -18,14 +18,19 @@ let params; describe('connectMultiRange', () => { it('provides the correct props to the component', () => { + let results = { + getFacetStats: () => ({min: 0, max: 300}), + getFacetByName: () => true, + }; + props = getProvidedProps({ items: [ {label: 'All'}, ], - }, {}); + }, {}, {results}); expect(props).toEqual({ items: [ - {label: 'All', value: '', isRefined: true}, + {label: 'All', value: '', isRefined: true, noRefinement: false}, ], currentRefinement: '', canRefine: true, @@ -36,11 +41,11 @@ describe('connectMultiRange', () => { {label: 'All'}, {label: 'Ok', start: 100}, ], - }, {}); + }, {}, {results}); expect(props).toEqual({ items: [ - {label: 'All', value: '', isRefined: true}, - {label: 'Ok', value: '100:', isRefined: false}, + {label: 'All', value: '', isRefined: true, noRefinement: false}, + {label: 'Ok', value: '100:', isRefined: false, noRefinement: false}, ], currentRefinement: '', canRefine: true, @@ -51,12 +56,11 @@ describe('connectMultiRange', () => { {label: 'All'}, {label: 'Not ok', end: 200}, ], - canRefine: true, - }, {}); + }, {}, {results}); expect(props).toEqual({ items: [ - {label: 'All', value: '', isRefined: true}, - {label: 'Not ok', value: ':200', isRefined: false}, + {label: 'All', value: '', isRefined: true, noRefinement: false}, + {label: 'Not ok', value: ':200', isRefined: false, noRefinement: false}, ], currentRefinement: '', canRefine: true, @@ -69,45 +73,74 @@ describe('connectMultiRange', () => { {label: 'Not ok', end: 200}, {label: 'Maybe ok?', start: 100, end: 200}, ], - canRefine: true, - }, {}); + }, {}, {results}); expect(props).toEqual({ items: [ - {label: 'All', value: '', isRefined: true}, - {label: 'Ok', value: '100:', isRefined: false}, - {label: 'Not ok', value: ':200', isRefined: false}, - {label: 'Maybe ok?', value: '100:200', isRefined: false}, + {label: 'All', value: '', isRefined: true, noRefinement: false}, + {label: 'Ok', value: '100:', isRefined: false, noRefinement: false}, + {label: 'Not ok', value: ':200', isRefined: false, noRefinement: false}, + {label: 'Maybe ok?', value: '100:200', isRefined: false, noRefinement: false}, ], currentRefinement: '', canRefine: true, }); - props = getProvidedProps({attributeName: 'ok', items: []}, {multiRange: {ok: 'wat'}}); - expect(props).toEqual({items: [], currentRefinement: 'wat', canRefine: false}); + it('no items define', () => { + props = getProvidedProps({attributeName: 'ok', items: []}, {multiRange: {ok: 'wat'}}, {}); + expect(props).toEqual({items: [], currentRefinement: 'wat', canRefine: false}); - props = getProvidedProps({attributeName: 'ok', items: []}, {multiRange: {ok: 'wat'}}); - expect(props).toEqual({items: [], currentRefinement: 'wat', canRefine: false}); + props = getProvidedProps({attributeName: 'ok', items: []}, {multiRange: {ok: 'wat'}}, {}); + expect(props).toEqual({items: [], currentRefinement: 'wat', canRefine: false}); - props = getProvidedProps({attributeName: 'ok', items: [], defaultRefinement: 'wat'}, {}); - expect(props).toEqual({items: [], currentRefinement: 'wat', canRefine: false}); + props = getProvidedProps({attributeName: 'ok', items: [], defaultRefinement: 'wat'}, {}, {}); + expect(props).toEqual({items: [], currentRefinement: 'wat', canRefine: false}); + }); - const transformItems = jest.fn(() => ['items']); - props = getProvidedProps({ - items: [ + it('use the transform items props if passed', () => { + const transformItems = jest.fn(() => ['items']); + props = getProvidedProps({ + items: [ {label: 'All'}, {label: 'Ok', start: 100}, {label: 'Not ok', end: 200}, {label: 'Maybe ok?', start: 100, end: 200}, - ], - transformItems, - }, {}); - expect(transformItems.mock.calls[0][0]).toEqual([ - {label: 'All', value: '', isRefined: true}, - {label: 'Ok', value: '100:', isRefined: false}, - {label: 'Not ok', value: ':200', isRefined: false}, - {label: 'Maybe ok?', value: '100:200', isRefined: false}, - ]); - expect(props.items).toEqual(['items']); + ], + transformItems, + }, {}, {results}); + expect(transformItems.mock.calls[0][0]).toEqual([ + {label: 'All', value: '', isRefined: true, noRefinement: false}, + {label: 'Ok', value: '100:', isRefined: false, noRefinement: false}, + {label: 'Not ok', value: ':200', isRefined: false, noRefinement: false}, + {label: 'Maybe ok?', value: '100:200', isRefined: false, noRefinement: false}, + ]); + expect(props.items).toEqual(['items']); + }); + + it('compute the no refinement value for each item range when stats exists', () => { + results = { + getFacetStats: () => ({min: 250, max: 300}), + getFacetByName: () => true, + }; + + props = getProvidedProps({ + items: [ + {label: '1', start: 100}, + {label: '2', start: 400}, + {label: '3', end: 200}, + {label: '4', start: 100, end: 200}, + ], + }, {}, {results}); + expect(props).toEqual({ + items: [ + {label: '1', value: '100:', isRefined: false, noRefinement: false}, + {label: '2', value: '400:', isRefined: false, noRefinement: true}, + {label: '3', value: ':200', isRefined: false, noRefinement: true}, + {label: '4', value: '100:200', isRefined: false, noRefinement: true}, + ], + currentRefinement: '', + canRefine: true, + }); + }); }); it('calling refine updates the widget\'s search state', () => { diff --git a/packages/react-instantsearch/src/widgets/MultiRange.js b/packages/react-instantsearch/src/widgets/MultiRange.js index bfe19c907a..54d04aa617 100644 --- a/packages/react-instantsearch/src/widgets/MultiRange.js +++ b/packages/react-instantsearch/src/widgets/MultiRange.js @@ -17,7 +17,8 @@ import MultiRangeComponent from '../components/MultiRange.js'; * @themeKey ais-MultiRange__itemLabelSelected - The selected label item * @themeKey ais-MultiRange__itemRadio - The radio of an item * @themeKey ais-MultiRange__itemRadioSelected - The selected radio item - * @themeKey ais-MultiRange__noRefinement - present when there is no refinement + * @themeKey ais-MultiRange__noRefinement - present when there is no refinement for all ranges + * @themeKey ais-MultiRange__itemNoRefinement - present when there is no refinement for one range * @example * import React from 'react'; *