diff --git a/package.json b/package.json index 7a252655a1..328592646a 100644 --- a/package.json +++ b/package.json @@ -130,7 +130,7 @@ }, { "path": "packages/react-instantsearch/dist/umd/Connectors.min.js", - "maxSize": "40.50 kB" + "maxSize": "40.75 kB" }, { "path": "packages/react-instantsearch/dist/umd/Dom.min.js", @@ -138,11 +138,11 @@ }, { "path": "packages/react-instantsearch-core/dist/umd/ReactInstantSearchCore.min.js", - "maxSize": "41.25 kB" + "maxSize": "41.50 kB" }, { "path": "packages/react-instantsearch-dom/dist/umd/ReactInstantSearchDOM.min.js", - "maxSize": "62.75 kB" + "maxSize": "63.00 kB" }, { "path": "packages/react-instantsearch-dom-maps/dist/umd/ReactInstantSearchDOMMaps.min.js", diff --git a/packages/react-instantsearch-core/src/connectors/__tests__/connectInfiniteHits.js b/packages/react-instantsearch-core/src/connectors/__tests__/connectInfiniteHits.js index 361faad10a..e87c43819f 100644 --- a/packages/react-instantsearch-core/src/connectors/__tests__/connectInfiniteHits.js +++ b/packages/react-instantsearch-core/src/connectors/__tests__/connectInfiniteHits.js @@ -8,8 +8,10 @@ describe('connectInfiniteHits', () => { context: { ais: { mainTargetedIndex: 'index', + onInternalStateUpdate: jest.fn(), }, }, + refine: jest.fn(), }); it('provides the current hits to the component', () => { @@ -23,7 +25,10 @@ describe('connectInfiniteHits', () => { expect(props).toEqual({ hits: hits.map(hit => expect.objectContaining(hit)), + hasPrevious: false, hasMore: true, + refinePrevious: expect.any(Function), + refineNext: expect.any(Function), }); }); @@ -55,6 +60,43 @@ describe('connectInfiniteHits', () => { expect(res2.hasMore).toBe(true); }); + it('prepend hits internally', () => { + const context = createSingleIndexContext(); + const getProvidedProps = connect.getProvidedProps.bind(context); + + const initialPageHits = [{}, {}]; + const previousPageHits = [{}, {}]; + const initialPageProps = getProvidedProps(null, null, { + results: { + hits: initialPageHits, + page: 1, + hitsPerPage: 2, + nbPages: 3, + }, + }); + + expect(initialPageProps.hits).toEqual( + initialPageHits.map(hit => expect.objectContaining(hit)) + ); + expect(initialPageProps.hasPrevious).toBe(true); + + const previousPageProps = getProvidedProps(null, null, { + results: { + hits: previousPageHits, + page: 0, + hitsPerPage: 2, + nbPages: 3, + }, + }); + + expect(previousPageProps.hits).toEqual( + [...previousPageHits, ...initialPageHits].map(hit => + expect.objectContaining(hit) + ) + ); + expect(previousPageProps.hasPrevious).toBe(false); + }); + it('accumulate hits internally while changing hitsPerPage configuration', () => { const context = createSingleIndexContext(); const getProvidedProps = connect.getProvidedProps.bind(context); @@ -373,7 +415,59 @@ describe('connectInfiniteHits', () => { expect(props.hasMore).toBe(false); }); - it('adds 1 to page when calling refine', () => { + it('calls refine with next page when calling refineNext', () => { + const context = createSingleIndexContext(); + const getProvidedProps = connect.getProvidedProps.bind(context); + + const hits = [{}, {}]; + const event = new Event('click'); + + const props = getProvidedProps( + {}, + {}, + { + results: { + hits, + page: 2, + hitsPerPage: 2, + nbPages: 3, + }, + } + ); + + props.refineNext.apply(context, [event]); + + expect(context.refine).toHaveBeenCalledTimes(1); + expect(context.refine).toHaveBeenLastCalledWith(event, 3); + }); + + it('calls refine with previous page when calling refinePrevious', () => { + const context = createSingleIndexContext(); + const getProvidedProps = connect.getProvidedProps.bind(context); + + const hits = [{}, {}]; + const event = new Event('click'); + + const props = getProvidedProps( + {}, + {}, + { + results: { + hits, + page: 2, + hitsPerPage: 2, + nbPages: 3, + }, + } + ); + + props.refinePrevious.apply(context, [event]); + + expect(context.refine).toHaveBeenCalledTimes(1); + expect(context.refine).toHaveBeenLastCalledWith(event, 1); + }); + + it('adds 1 to page when calling refine without index', () => { const context = createSingleIndexContext(); const refine = connect.refine.bind(context); @@ -387,6 +481,20 @@ describe('connectInfiniteHits', () => { expect(state2).toEqual({ page: 3 }); }); + it('set page to the corresponding index', () => { + const context = createSingleIndexContext(); + const refine = connect.refine.bind(context); + + const props = {}; + const state0 = {}; + const event = new Event('click'); + const index = 5; + + const state1 = refine(props, state0, event, index); + // `index` is indexed from 0 but page number is indexed from 1 + expect(state1).toEqual({ page: 6 }); + }); + it('automatically converts String state to Number', () => { const context = createSingleIndexContext(); const refine = connect.refine.bind(context); @@ -418,7 +526,10 @@ describe('connectInfiniteHits', () => { const expectation = { hits: [{}, {}, {}].map(hit => expect.objectContaining(hit)), + hasPrevious: true, hasMore: true, + refinePrevious: expect.any(Function), + refineNext: expect.any(Function), }; const actual = getProvidedProps(props, searchState, searchResults); @@ -450,7 +561,10 @@ describe('connectInfiniteHits', () => { expect(props).toEqual({ hits: hits.map(hit => expect.objectContaining(hit)), + hasPrevious: false, hasMore: true, + refinePrevious: expect.any(Function), + refineNext: expect.any(Function), }); }); @@ -480,6 +594,47 @@ describe('connectInfiniteHits', () => { expect(res2.hasMore).toBe(true); }); + it('prepend hits internally', () => { + const context = createMultiIndexContext(); + const getProvidedProps = connect.getProvidedProps.bind(context); + + const initialPageHits = [{}, {}]; + const previousPageHits = [{}, {}]; + const initialPageProps = getProvidedProps(null, null, { + results: { + second: { + hits: initialPageHits, + page: 1, + hitsPerPage: 2, + nbPages: 3, + }, + }, + }); + + expect(initialPageProps.hits).toEqual( + initialPageHits.map(hit => expect.objectContaining(hit)) + ); + expect(initialPageProps.hasPrevious).toBe(true); + + const previousPageProps = getProvidedProps(null, null, { + results: { + second: { + hits: previousPageHits, + page: 0, + hitsPerPage: 2, + nbPages: 3, + }, + }, + }); + + expect(previousPageProps.hits).toEqual( + [...previousPageHits, ...initialPageHits].map(hit => + expect.objectContaining(hit) + ) + ); + expect(previousPageProps.hasPrevious).toBe(false); + }); + it('accumulate hits internally while changing hitsPerPage configuration', () => { const context = createMultiIndexContext(); const getProvidedProps = connect.getProvidedProps.bind(context); @@ -530,6 +685,44 @@ describe('connectInfiniteHits', () => { expect(res3.hasMore).toBe(true); }); + it('should not accumulate hits internally while changing query', () => { + const context = createMultiIndexContext(); + const getProvidedProps = connect.getProvidedProps.bind(context); + + const hits = [{}, {}, {}, {}, {}, {}]; + const hits2 = [{}, {}, {}, {}, {}, {}]; + + const res1 = getProvidedProps(null, null, { + results: { + second: { + hits, + page: 0, + hitsPerPage: 6, + nbPages: 10, + _state: { page: 0, query: 'a' }, + }, + }, + }); + + expect(res1.hits).toEqual(hits.map(hit => expect.objectContaining(hit))); + expect(res1.hasMore).toBe(true); + + const res2 = getProvidedProps(null, null, { + results: { + second: { + hits: hits2, + page: 0, + hitsPerPage: 6, + nbPages: 10, + _state: { page: 0, query: 'b' }, + }, + }, + }); + + expect(res2.hits).toEqual(hits2.map(hit => expect.objectContaining(hit))); + expect(res2.hasMore).toBe(true); + }); + it('should not reset while accumulating results', () => { const context = createMultiIndexContext(); const getProvidedProps = connect.getProvidedProps.bind(context); diff --git a/packages/react-instantsearch-core/src/connectors/connectInfiniteHits.js b/packages/react-instantsearch-core/src/connectors/connectInfiniteHits.js index 4604907f5b..84f90406b4 100644 --- a/packages/react-instantsearch-core/src/connectors/connectInfiniteHits.js +++ b/packages/react-instantsearch-core/src/connectors/connectInfiniteHits.js @@ -1,3 +1,5 @@ +import { isEqual } from 'lodash'; + import createConnector from '../core/createConnector'; import { getCurrentRefinementValue, @@ -45,16 +47,26 @@ export default createConnector({ const results = getResults(searchResults, this.context); this._allResults = this._allResults || []; - this._previousPage = this._previousPage || 0; + this._prevState = this._prevState || {}; if (!results) { return { hits: [], + hasPrevious: false, hasMore: false, + refine: () => {}, + refinePrevious: () => {}, + refineNext: () => {}, }; } - const { hits, hitsPerPage, page, nbPages } = results; + const { + page, + hits, + hitsPerPage, + nbPages, + _state: { page: p, ...currentState } = {}, + } = results; const hitsWithPositions = addAbsolutePositions(hits, hitsPerPage, page); const hitsWithPositionsAndQueryID = addQueryID( @@ -62,22 +74,36 @@ export default createConnector({ results.queryID ); - if (page === 0) { - this._allResults = hitsWithPositionsAndQueryID; - } else if (page > this._previousPage) { + if ( + this._firstReceivedPage === undefined || + !isEqual(currentState, this._prevState) + ) { + this._allResults = [...hitsWithPositionsAndQueryID]; + this._firstReceivedPage = page; + this._lastReceivedPage = page; + } else if (this._lastReceivedPage < page) { this._allResults = [...this._allResults, ...hitsWithPositionsAndQueryID]; - } else if (page < this._previousPage) { - this._allResults = hitsWithPositionsAndQueryID; + this._lastReceivedPage = page; + } else if (this._firstReceivedPage > page) { + this._allResults = [...hitsWithPositionsAndQueryID, ...this._allResults]; + this._firstReceivedPage = page; } + this._prevState = currentState; + + const hasPrevious = this._firstReceivedPage > 0; const lastPageIndex = nbPages - 1; const hasMore = page < lastPageIndex; - - this._previousPage = page; + const refinePrevious = event => + this.refine(event, this._firstReceivedPage - 1); + const refineNext = event => this.refine(event, this._lastReceivedPage + 1); return { hits: this._allResults, + hasPrevious, hasMore, + refinePrevious, + refineNext, }; }, @@ -87,10 +113,14 @@ export default createConnector({ }); }, - refine(props, searchState) { + refine(props, searchState, event, index) { + if (index === undefined && this._lastReceivedPage !== undefined) { + index = this._lastReceivedPage + 1; + } else if (index === undefined) { + index = getCurrentRefinement(props, searchState, this.context); + } const id = getId(); - const nextPage = getCurrentRefinement(props, searchState, this.context) + 1; - const nextValue = { [id]: nextPage }; + const nextValue = { [id]: index + 1 }; // `index` is indexed from 0 but page number is indexed from 1 const resetPage = false; return refineValue(searchState, nextValue, this.context, resetPage); }, diff --git a/packages/react-instantsearch-dom/src/components/InfiniteHits.js b/packages/react-instantsearch-dom/src/components/InfiniteHits.js index b6e91975f0..cbeacde223 100644 --- a/packages/react-instantsearch-dom/src/components/InfiniteHits.js +++ b/packages/react-instantsearch-dom/src/components/InfiniteHits.js @@ -11,14 +11,29 @@ class InfiniteHits extends Component { const { hitComponent: HitComponent, hits, + showPrevious, + hasPrevious, hasMore, - refine, + refinePrevious, + refineNext, translate, className, } = this.props; return (
+ {showPrevious && ( + + )} +
    + {hits.map(hit => ( +
  1. {hit.name}
  2. + ))} +
+ +
+ ); + + MyInfiniteHits.propTypes = { + hits: PropTypes.array.isRequired, + hasMore: PropTypes.bool.isRequired, + hasPrevious: PropTypes.bool.isRequired, + refine: PropTypes.func.isRequired, + refinePrevious: PropTypes.func.isRequired, + }; + + const CustomInfiniteHits = connectInfiniteHits(MyInfiniteHits); + + return ( + { + urlLogger(JSON.stringify(searchState, null, 2)); + }} + > + + + ); + }) + .add('with custom hitComponent', () => { function Product({ hit }) { return (
diff --git a/stories/util.js b/stories/util.js index 8fbf08c2d0..e3399c1dff 100644 --- a/stories/util.js +++ b/stories/util.js @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useState } from 'react'; import PropTypes from 'prop-types'; import { linkTo } from '@storybook/addon-links'; import { @@ -73,6 +73,8 @@ export const WrapWithHits = ({ apiKey, indexName, hitsElement, + initialSearchState, + onSearchStateChange, }) => { const sourceCodeUrl = `https://github.com/algolia/react-instantsearch/tree/master/stories/${linkedStoryGroup}.stories.js`; const playgroundLink = hasPlayground ? ( @@ -105,8 +107,22 @@ export const WrapWithHits = ({ ...askedSearchParameters, }; + const [searchState, setSearchState] = useState(initialSearchState); + + const setNextSearchState = nextSearchState => { + setSearchState(nextSearchState); + onSearchStateChange(nextSearchState); + }; + return ( - + ''} + >
{children}
@@ -148,6 +164,8 @@ WrapWithHits.propTypes = { pagination: PropTypes.bool, searchParameters: PropTypes.object, hitsElement: PropTypes.element, + initialSearchState: PropTypes.object, + onSearchStateChange: PropTypes.func, }; // defaultProps added so that they're displayed in the JSX addon @@ -155,4 +173,6 @@ WrapWithHits.defaultProps = { appId: 'latency', apiKey: '6be0576ff61c053d5f9a3225e2a90f76', indexName: 'instant_search', + initialSearchState: {}, + onSearchStateChange: () => {}, };