diff --git a/README.md b/README.md index 94c00cc7..c4c28734 100644 --- a/README.md +++ b/README.md @@ -85,8 +85,7 @@ const searchClient = instantMeiliSearch( `instant-meilisearch` offers some options you can set to further fit your needs. - [`placeholderSearch`](#placeholder-search): Enable or disable placeholder search (default: `true`). -- [`paginationTotalHits`](#pagination-total-hits): Maximum total number of hits to create a finite pagination (default: `200`). -- [`finitePagination`](#finite-pagination): Used to work with the [`pagination`](#-pagination) widget (default: `false`) . +- [`finitePagination`](#finite-pagination): Enable finite pagination when using the the [`pagination`](#-pagination) widget (default: `false`) . - [`primaryKey`](#primary-key): Specify the primary key of your documents (default `undefined`). - [`keepZeroFacets`](#keep-zero-facets): Show the facets value even when they have 0 matches (default `false`). - [`matchingStrategy`](#matching-strategy): Determine the search strategy on words matching (default `last`). @@ -100,7 +99,6 @@ const searchClient = instantMeiliSearch( 'https://integration-demos.meilisearch.com', '99d1e034ed32eb569f9edc27962cccf90b736e4c5a70f7f5e76b9fab54d6a185', { - paginationTotalHits: 30, // default: 200. placeholderSearch: false, // default: true. primaryKey: 'id', // default: undefined // ... @@ -117,25 +115,11 @@ When placeholder search is set to `false`, no results appears when searching on { placeholderSearch : true } // default true ``` -### Pagination total hits - -The total (and finite) number of hits (default: `200`) you can browse during pagination when using the [pagination widget](https://www.algolia.com/doc/api-reference/widgets/pagination/js/) or the [`infiniteHits` widget](#-infinitehits). If none of these widgets are used, `paginationTotalHits` is ignored.
- -For example, using the `infiniteHits` widget, and a `paginationTotalHits` of 9. On the first search request 6 hits are shown, by clicking a second time on `load more` only 3 more hits are added. This is because `paginationTotalHits` is `9`. - -Usage: - -```js -{ paginationTotalHits: 50 } // default: 200 -``` - -`hitsPerPage` has a value of `20` by default and can [be customized](#-hitsperpage). - ### Finite Pagination Finite pagination is used when you want to add a numbered pagination at the bottom of your hits (for example: `<< < 1, 2, 3 > >>`). -To be able to know the amount of page numbers you have, a search is done requesting `paginationTotalHits` documents (default: `200`). -With the amount of documents returned, instantsearch is able to render the correct amount of numbers in the pagination widget. + +It requires the usage of the [`Pagination` widget](#-pagination). Example: @@ -143,8 +127,6 @@ Example: { finitePagination: true } // default: false ``` -⚠️ Meilisearch is not designed for pagination and this can lead to performances issues, so the usage `finitePagination` but also of the pagination widgets are not recommended.
-More information about Meilisearch and the pagination [here](https://github.com/meilisearch/documentation/issues/561). ### Primary key @@ -925,9 +907,7 @@ instantsearch.widgets.clearRefinements({ [Pagination references](https://www.algolia.com/doc/api-reference/widgets/pagination/js/) -The `pagination` widget displays a pagination system allowing the user to change the current page. - -We do not recommend using this widget as pagination slows the search responses. Instead, the [InfiniteHits](#-infinitehits) component is recommended. +The `pagination` widget displays a pagination system allowing the user to change the current page. It should be used alongside the [`finitePagination`](#finite-pagination) setting to render the correct amount of pages. - ✅ container: The CSS Selector or HTMLElement to insert the widget into. _required_ - ✅ showFirst: Whether to display the first-page link. @@ -1094,4 +1074,4 @@ If you want to know more about the development workflow or want to contribute, p
-**Meilisearch** provides and maintains many **SDKs and Integration tools** like this one. We want to provide everyone with an **amazing search experience for any kind of project**. If you want to contribute, make suggestions, or just know what's going on right now, visit us in the [integration-guides](https://github.com/meilisearch/integration-guides) repository. \ No newline at end of file +**Meilisearch** provides and maintains many **SDKs and Integration tools** like this one. We want to provide everyone with an **amazing search experience for any kind of project**. If you want to contribute, make suggestions, or just know what's going on right now, visit us in the [integration-guides](https://github.com/meilisearch/integration-guides) repository. diff --git a/playgrounds/react/src/App.js b/playgrounds/react/src/App.js index d8936b8a..6e84b522 100644 --- a/playgrounds/react/src/App.js +++ b/playgrounds/react/src/App.js @@ -20,7 +20,6 @@ const searchClient = instantMeiliSearch( 'https://integration-demos.meilisearch.com', '99d1e034ed32eb569f9edc27962cccf90b736e4c5a70f7f5e76b9fab54d6a185', { - paginationTotalHits: 60, primaryKey: 'id', } ) diff --git a/src/adapter/search-request-adapter/__tests__/search-params.tests.ts b/src/adapter/search-request-adapter/__tests__/search-params.tests.ts index 2feaf5e1..3e0a3aa1 100644 --- a/src/adapter/search-request-adapter/__tests__/search-params.tests.ts +++ b/src/adapter/search-request-adapter/__tests__/search-params.tests.ts @@ -3,9 +3,8 @@ import { MatchingStrategies } from '../../../types' const DEFAULT_CONTEXT = { indexUid: 'test', - pagination: { paginationTotalHits: 20, page: 0, hitsPerPage: 6 }, + pagination: { page: 0, hitsPerPage: 6, finite: false }, defaultFacetDistribution: {}, - finitePagination: false, } describe('Parameters adapter', () => { @@ -108,86 +107,63 @@ describe('Pagination adapter', () => { test('adapting a searchContext with finite pagination', () => { const searchParams = adaptSearchParams({ ...DEFAULT_CONTEXT, - finitePagination: true, + pagination: { page: 0, hitsPerPage: 6, finite: true }, }) - expect(searchParams.limit).toBe(20) + expect(searchParams.page).toBe(1) + expect(searchParams.hitsPerPage).toBe(6) }) test('adapting a searchContext with finite pagination on a later page', () => { const searchParams = adaptSearchParams({ ...DEFAULT_CONTEXT, - pagination: { paginationTotalHits: 20, page: 10, hitsPerPage: 6 }, - finitePagination: true, + pagination: { page: 10, hitsPerPage: 6, finite: true }, }) - expect(searchParams.limit).toBe(20) + expect(searchParams.page).toBe(11) + expect(searchParams.hitsPerPage).toBe(6) }) - test('adapting a searchContext with finite pagination and pagination total hits lower than hitsPerPage', () => { - const searchParams = adaptSearchParams({ - ...DEFAULT_CONTEXT, - pagination: { paginationTotalHits: 4, page: 0, hitsPerPage: 6 }, - finitePagination: true, - }) - - expect(searchParams.limit).toBe(4) - }) - - test('adapting a searchContext with no finite pagination', () => { + test('adapting a searchContext with no finite pagination on page 1', () => { const searchParams = adaptSearchParams({ ...DEFAULT_CONTEXT, }) expect(searchParams.limit).toBe(7) + expect(searchParams.offset).toBe(0) }) test('adapting a searchContext with no finite pagination on page 2', () => { const searchParams = adaptSearchParams({ ...DEFAULT_CONTEXT, - pagination: { paginationTotalHits: 20, page: 1, hitsPerPage: 6 }, - }) - - expect(searchParams.limit).toBe(13) - }) - - test('adapting a searchContext with no finite pagination on page higher than paginationTotalHits', () => { - const searchParams = adaptSearchParams({ - ...DEFAULT_CONTEXT, - pagination: { paginationTotalHits: 20, page: 40, hitsPerPage: 6 }, + pagination: { page: 1, hitsPerPage: 6, finite: false }, }) - expect(searchParams.limit).toBe(20) - }) - - test('adapting a searchContext with no finite pagination and pagination total hits lower than hitsPerPage', () => { - const searchParams = adaptSearchParams({ - ...DEFAULT_CONTEXT, - pagination: { paginationTotalHits: 4, page: 0, hitsPerPage: 6 }, - }) - - expect(searchParams.limit).toBe(4) + expect(searchParams.limit).toBe(7) + expect(searchParams.offset).toBe(6) }) - test('adapting a searchContext placeholderSearch set to false', () => { + test('adapting a finite pagination with no placeholderSearch', () => { const searchParams = adaptSearchParams({ ...DEFAULT_CONTEXT, query: '', - pagination: { paginationTotalHits: 4, page: 0, hitsPerPage: 6 }, + pagination: { page: 4, hitsPerPage: 6, finite: true }, placeholderSearch: false, }) - expect(searchParams.limit).toBe(0) + expect(searchParams.page).toBe(5) + expect(searchParams.hitsPerPage).toBe(0) }) - test('adapting a searchContext placeholderSearch set to false', () => { + test('adapting a scroll pagination with no placeholderSearch', () => { const searchParams = adaptSearchParams({ ...DEFAULT_CONTEXT, query: '', - pagination: { paginationTotalHits: 200, page: 0, hitsPerPage: 6 }, - placeholderSearch: true, + pagination: { page: 4, hitsPerPage: 6, finite: false }, + placeholderSearch: false, }) - expect(searchParams.limit).toBe(7) + expect(searchParams.limit).toBe(0) + expect(searchParams.offset).toBe(0) }) }) diff --git a/src/adapter/search-request-adapter/search-params-adapter.ts b/src/adapter/search-request-adapter/search-params-adapter.ts index 46771e57..af8004f1 100644 --- a/src/adapter/search-request-adapter/search-params-adapter.ts +++ b/src/adapter/search-request-adapter/search-params-adapter.ts @@ -6,6 +6,43 @@ import { } from './geo-rules-adapter' import { adaptFilters } from './filter-adapter' +function setScrollPagination( + hitsPerPage: number, + page: number, + query?: string, + placeholderSearch?: boolean +): { limit: number; offset: number } { + if (!placeholderSearch && query === '') { + return { + limit: 0, + offset: 0, + } + } + + return { + limit: hitsPerPage + 1, + offset: page * hitsPerPage, + } +} + +function setFinitePagination( + hitsPerPage: number, + page: number, + query?: string, + placeholderSearch?: boolean +): { hitsPerPage: number; page: number } { + if (!placeholderSearch && query === '') { + return { + hitsPerPage: 0, + page: page + 1, + } + } else { + return { + hitsPerPage: hitsPerPage, + page: page + 1, + } + } +} /** * Adapts instantsearch.js and instant-meilisearch options * to meilisearch search query parameters. @@ -29,7 +66,6 @@ export function MeiliParamsCreator(searchContext: SearchContext) { highlightPostTag, placeholderSearch, query, - finitePagination, sort, pagination, matchingStrategy, @@ -84,23 +120,24 @@ export function MeiliParamsCreator(searchContext: SearchContext) { } }, addPagination() { - // Limit based on pagination preferences - if ( - (!placeholderSearch && query === '') || - pagination.paginationTotalHits === 0 - ) { - meiliSearchParams.limit = 0 - } else if (finitePagination) { - meiliSearchParams.limit = pagination.paginationTotalHits + if (pagination.finite) { + const { hitsPerPage, page } = setFinitePagination( + pagination.hitsPerPage, + pagination.page, + query, + placeholderSearch + ) + meiliSearchParams.hitsPerPage = hitsPerPage + meiliSearchParams.page = page } else { - const limit = (pagination.page + 1) * pagination.hitsPerPage + 1 - // If the limit is bigger than the total hits accepted - // force the limit to that amount - if (limit > pagination.paginationTotalHits) { - meiliSearchParams.limit = pagination.paginationTotalHits - } else { - meiliSearchParams.limit = limit - } + const { limit, offset } = setScrollPagination( + pagination.hitsPerPage, + pagination.page, + query, + placeholderSearch + ) + meiliSearchParams.limit = limit + meiliSearchParams.offset = offset } }, addSort() { diff --git a/src/adapter/search-request-adapter/search-resolver.ts b/src/adapter/search-request-adapter/search-resolver.ts index 7dcc7c4d..13cb15cc 100644 --- a/src/adapter/search-request-adapter/search-resolver.ts +++ b/src/adapter/search-request-adapter/search-resolver.ts @@ -27,27 +27,19 @@ export function SearchResolver( ): Promise>> { const { placeholderSearch, query } = searchContext - const { pagination } = searchContext - - // In case we are in a `finitePagination`, only one big request is made - // containing a total of max the paginationTotalHits (default: 200). - // Thus we dont want the pagination to impact the cache as every - // hits are already cached. - const paginationCache = searchContext.finitePagination ? {} : pagination - // Create cache key containing a unique set of search parameters const key = cache.formatKey([ searchParams, searchContext.indexUid, searchContext.query, - paginationCache, + searchContext.pagination, ]) const cachedResponse = cache.getEntry(key) // Check if specific request is already cached with its associated search response. if (cachedResponse) return cachedResponse - const facetsCache = extractFacets(searchContext, searchParams) + const cachedFacets = extractFacets(searchContext, searchParams) // Make search request const searchResponse = await client @@ -56,7 +48,7 @@ export function SearchResolver( // Add missing facets back into facetDistribution searchResponse.facetDistribution = addMissingFacets( - facetsCache, + cachedFacets, searchResponse.facetDistribution ) diff --git a/src/adapter/search-response-adapter/__tests__/pagination-adapter.tests.ts b/src/adapter/search-response-adapter/__tests__/pagination-adapter.tests.ts index f0fdad98..953aa650 100644 --- a/src/adapter/search-response-adapter/__tests__/pagination-adapter.tests.ts +++ b/src/adapter/search-response-adapter/__tests__/pagination-adapter.tests.ts @@ -1,31 +1,36 @@ -import { adaptPagination } from '../pagination-adapter' -import { ceiledDivision } from '../../../utils' +import { adaptPaginationParameters } from '../pagination-adapter' +import { ceiledDivision } from '../../../../tests/assets/number' const numberPagesTestParameters = [ { hitsPerPage: 0, hitsLength: 100, numberPages: 0, + page: 0, }, { hitsPerPage: 1, hitsLength: 100, numberPages: 100, + page: 1, }, { hitsPerPage: 20, hitsLength: 24, numberPages: 2, + page: 1, }, { hitsPerPage: 20, hitsLength: 0, numberPages: 0, + page: 1, }, { hitsPerPage: 0, hitsLength: 0, numberPages: 0, + page: 1, }, // Not an Algolia behavior. Algolia returns an error: // "Value too small for \"hitsPerPage\" parameter, expected integer between 0 and 9223372036854775807", @@ -36,91 +41,334 @@ const numberPagesTestParameters = [ }, ] -const paginateHitsTestsParameters = [ +const finitePaginateHitsTestsParameters = [ // Empty hits { - hits: [], - page: 0, - hitsPerPage: 20, - returnedHits: [], + searchResponse: { + hits: [], + page: 0, + hitsPerPage: 20, + totalPages: 0, + }, + adaptedPagination: { + page: 0, + hitsPerPage: 20, + nbPages: 0, + }, }, { - hits: [], - page: 100, - hitsPerPage: 0, - returnedHits: [], + searchResponse: { hits: [], page: 100, hitsPerPage: 0, totalPages: 0 }, + adaptedPagination: { + page: 100, + hitsPerPage: 0, + nbPages: 0, + }, }, { - hits: [], - page: 100, - hitsPerPage: 20, - returnedHits: [], + searchResponse: { hits: [], page: 100, hitsPerPage: 20, totalPages: 0 }, + adaptedPagination: { + page: 100, + hitsPerPage: 20, + nbPages: 0, + }, }, // Page 0 { - hits: [{ id: 1 }, { id: 2 }, { id: 3 }], - page: 0, - hitsPerPage: 20, - returnedHits: [{ id: 1 }, { id: 2 }, { id: 3 }], + searchResponse: { + hits: [{ id: 1 }, { id: 2 }, { id: 3 }], + page: 0, + hitsPerPage: 20, + totalPages: 1, + }, + adaptedPagination: { + page: 0, + hitsPerPage: 20, + nbPages: 1, + }, }, { - hits: [{ id: 1 }, { id: 2 }, { id: 3 }], - page: 0, - hitsPerPage: 0, - returnedHits: [], + searchResponse: { + hits: [{ id: 1 }, { id: 2 }, { id: 3 }], + page: 0, + hitsPerPage: 0, + totalPages: 0, + }, + adaptedPagination: { + page: 0, + hitsPerPage: 0, + nbPages: 0, + }, }, { - hits: [{ id: 1 }, { id: 2 }, { id: 3 }], - page: 0, - hitsPerPage: 20, - returnedHits: [{ id: 1 }, { id: 2 }, { id: 3 }], + searchResponse: { + hits: [{ id: 1 }, { id: 2 }, { id: 3 }], + page: 0, + hitsPerPage: 20, + totalPages: 1, + }, + adaptedPagination: { + page: 0, + hitsPerPage: 20, + nbPages: 1, + }, }, { - hits: [{ id: 1 }, { id: 2 }, { id: 3 }], - page: 0, - hitsPerPage: 2, - returnedHits: [{ id: 1 }, { id: 2 }], + searchResponse: { + hits: [{ id: 1 }, { id: 2 }, { id: 3 }], + page: 0, + hitsPerPage: 2, + totalPages: 2, + }, + adaptedPagination: { + page: 0, + hitsPerPage: 2, + nbPages: 2, + }, }, // Page 1 { - hits: [{ id: 1 }, { id: 2 }, { id: 3 }], - page: 1, - hitsPerPage: 2, - returnedHits: [{ id: 3 }], + searchResponse: { + hits: [{ id: 1 }, { id: 2 }, { id: 3 }], + page: 1, + hitsPerPage: 2, + totalPages: 2, + }, + adaptedPagination: { + page: 1, + hitsPerPage: 2, + nbPages: 2, + }, }, { - hits: [{ id: 1 }, { id: 2 }, { id: 3 }], - page: 1, - hitsPerPage: 20, - returnedHits: [], + searchResponse: { + hits: [{ id: 1 }, { id: 2 }, { id: 3 }], + page: 1, + hitsPerPage: 20, + totalPages: 1, + }, + adaptedPagination: { + page: 1, + hitsPerPage: 20, + nbPages: 1, + }, }, { - hits: [{ id: 1 }, { id: 2 }, { id: 3 }], - page: 1, - hitsPerPage: 0, - returnedHits: [], + searchResponse: { + hits: [{ id: 1 }, { id: 2 }, { id: 3 }], + page: 1, + hitsPerPage: 0, + totalPages: 0, + }, + adaptedPagination: { + page: 1, + hitsPerPage: 0, + nbPages: 0, + }, }, // Page 2 { - hits: [{ id: 1 }, { id: 2 }, { id: 3 }], - page: 2, - hitsPerPage: 20, - returnedHits: [], + searchResponse: { + hits: [{ id: 1 }, { id: 2 }, { id: 3 }], + page: 2, + hitsPerPage: 20, + totalPages: 1, + }, + adaptedPagination: { + page: 2, + hitsPerPage: 20, + nbPages: 1, + }, }, { - hits: [{ id: 1 }, { id: 2 }, { id: 3 }], - page: 2, - hitsPerPage: 20, - returnedHits: [], + searchResponse: { + hits: [{ id: 1 }, { id: 2 }, { id: 3 }], + page: 2, + hitsPerPage: 20, + totalPages: 1, + }, + adaptedPagination: { + hitsPerPage: 20, + nbPages: 1, + page: 2, + }, }, { - hits: [{ id: 1 }, { id: 2 }, { id: 3 }], - page: 2, - hitsPerPage: 0, - returnedHits: [], + searchResponse: { + hits: [{ id: 1 }, { id: 2 }, { id: 3 }], + page: 2, + hitsPerPage: 0, + totalPages: 0, + }, + adaptedPagination: { + page: 2, + nbPages: 0, + hitsPerPage: 0, + }, + }, +] + +const lazyPaginateHitsTestsParameters = [ + // Empty hits + { + searchResponse: { + hits: [], + limit: 21, + offset: 0, + }, + paginationState: { + hitsPerPage: 20, + page: 0, + }, + adaptedPagination: { + page: 0, + hitsPerPage: 20, + nbPages: 1, + }, + }, + { + searchResponse: { hits: [], limit: 0, offset: 0 }, + paginationState: { + page: 100, + hitsPerPage: 0, + }, + adaptedPagination: { + page: 100, + hitsPerPage: 0, + nbPages: 0, + }, + }, + { + searchResponse: { hits: [], limit: 21, offset: 0 }, + paginationState: { + page: 100, + hitsPerPage: 20, + }, + adaptedPagination: { + page: 100, + hitsPerPage: 20, + nbPages: 1, + }, + }, + + // // Page 0 + { + searchResponse: { + hits: [{ id: 1 }, { id: 2 }, { id: 3 }], + limit: 21, + offset: 0, + }, + paginationState: { + page: 0, + hitsPerPage: 20, + }, + adaptedPagination: { + page: 0, + hitsPerPage: 20, + nbPages: 1, + }, + }, + { + searchResponse: { + hits: [{ id: 1 }, { id: 2 }, { id: 3 }], + limit: 0, + offset: 0, + }, + paginationState: { + page: 0, + hitsPerPage: 0, + }, + adaptedPagination: { + page: 0, + hitsPerPage: 0, + nbPages: 0, + }, + }, + { + searchResponse: { + hits: [{ id: 1 }, { id: 2 }, { id: 3 }], + limit: 21, + offset: 0, + }, + paginationState: { + page: 0, + hitsPerPage: 20, + }, + adaptedPagination: { + page: 0, + hitsPerPage: 20, + nbPages: 1, + }, + }, + { + searchResponse: { + hits: [{ id: 1 }, { id: 2 }, { id: 3 }], + limit: 3, + offset: 0, + }, + paginationState: { + page: 0, + hitsPerPage: 2, + }, + adaptedPagination: { + page: 0, + hitsPerPage: 2, + nbPages: 2, + }, + }, + + // // Page 1 + { + searchResponse: { + hits: [{ id: 1 }, { id: 2 }, { id: 3 }], + limit: 2, + offset: 1, + }, + paginationState: { + page: 1, + hitsPerPage: 1, + }, + adaptedPagination: { + page: 1, + hitsPerPage: 1, + nbPages: 3, + }, + }, + { + searchResponse: { + hits: [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }, { id: 5 }], + limit: 3, + offset: 2, + }, + paginationState: { + page: 1, + hitsPerPage: 2, + }, + adaptedPagination: { + page: 1, + hitsPerPage: 2, + nbPages: 3, + }, + }, + + // Page 2 + { + searchResponse: { + hits: [{ id: 3 }], + limit: 2, + offset: 2, + }, + paginationState: { + page: 2, + hitsPerPage: 1, + }, + adaptedPagination: { + page: 2, + hitsPerPage: 1, + nbPages: 3, + }, }, ] @@ -129,34 +377,68 @@ describe.each(numberPagesTestParameters)( ({ hitsPerPage, hitsLength, numberPages }) => { it(`Should return ${numberPages} pages when hitsPerPage is ${hitsPerPage} and hits length is ${hitsLength}`, () => { const response = ceiledDivision(hitsLength, hitsPerPage) - expect(response).toBe(numberPages) }) } ) -describe.each(paginateHitsTestsParameters)( - 'Paginate hits tests', - ({ hits, page, hitsPerPage, returnedHits }) => { - it(`Should return ${JSON.stringify( - returnedHits +describe.each(finitePaginateHitsTestsParameters)( + 'Finite paginate hits tests', + ({ + searchResponse: { hits, page, hitsPerPage, totalPages }, + adaptedPagination, + }) => { + it(`should return ${JSON.stringify( + adaptedPagination )} when hitsPerPage is ${hitsPerPage}, number of page is ${page} and when hits is ${JSON.stringify( hits )}`, () => { - const response = adaptPagination(hits, page, hitsPerPage) + const response = adaptPaginationParameters( + { + hits, + page: page, + hitsPerPage, + processingTimeMs: 0, + query: '', + totalPages, + }, + { hitsPerPage, page, finite: true } + ) - expect(response).toEqual(returnedHits) + expect(response).toEqual(adaptedPagination) + }) + } +) + +describe.each(lazyPaginateHitsTestsParameters)( + 'Lazy paginate hits tests', + ({ + searchResponse: { hits, limit, offset }, + paginationState: { page, hitsPerPage }, + adaptedPagination, + }) => { + it(`should return ${JSON.stringify( + adaptedPagination + )} where limit is ${limit} in the response and where the instantsearch pagination context is page: ${page} and hitsPerPage: ${hitsPerPage}`, () => { + const response = adaptPaginationParameters( + { hits, limit, offset, processingTimeMs: 0, query: '' }, + { hitsPerPage, page, finite: false } + ) + + expect(response).toEqual(adaptedPagination) }) } ) it('Should throw when hitsPerPage is negative', () => { try { - const hits: string[] = [] + const hits: Array> = [] const hitsPerPage = -1 const page = 0 - - adaptPagination(hits, page, hitsPerPage) + adaptPaginationParameters( + { hits, page: page + 1, hitsPerPage, processingTimeMs: 0, query: '' }, + { hitsPerPage, page, finite: true } + ) } catch (e: any) { expect(e.message).toBe( 'Value too small for "hitsPerPage" parameter, expected integer between 0 and 9223372036854775807' diff --git a/src/adapter/search-response-adapter/hits-adapter.ts b/src/adapter/search-response-adapter/hits-adapter.ts index dae04ff8..a4c673ca 100644 --- a/src/adapter/search-response-adapter/hits-adapter.ts +++ b/src/adapter/search-response-adapter/hits-adapter.ts @@ -1,24 +1,30 @@ -import type { PaginationContext, SearchContext } from '../../types' -import { adaptPagination } from './pagination-adapter' +import type { SearchContext, MeiliSearchResponse } from '../../types' import { adaptFormattedFields } from './format-adapter' import { adaptGeoResponse } from './geo-reponse-adapter' /** - * @param {Array>} searchResponse * @param {SearchContext} searchContext - * @param {PaginationContext} paginationContext - * @returns {any} + * @returns {Array>} */ export function adaptHits( - hits: Array>, - searchContext: SearchContext, - paginationContext: PaginationContext + searchResponse: MeiliSearchResponse>, + searchContext: SearchContext ): any { const { primaryKey } = searchContext - const { hitsPerPage, page } = paginationContext - const paginatedHits = adaptPagination(hits, page, hitsPerPage) + const { hits } = searchResponse + const { + pagination: { finite, hitsPerPage }, + } = searchContext - let adaptedHits = paginatedHits.map((hit: Record) => { + // if the length of the hits is bigger than the hitsPerPage + // It means that there is still pages to come as we append limit by hitsPerPage + 1 + // In which case we still need to remove the additional hit returned by Meilisearch + if (!finite && hits.length > hitsPerPage) { + hits.splice(hits.length - 1, 1) + } + + let adaptedHits = hits.map((hit: Record) => { // Creates Hit object compliant with InstantSearch if (Object.keys(hit).length > 0) { const { diff --git a/src/adapter/search-response-adapter/pagination-adapter.ts b/src/adapter/search-response-adapter/pagination-adapter.ts index ddf69df7..5262d9de 100644 --- a/src/adapter/search-response-adapter/pagination-adapter.ts +++ b/src/adapter/search-response-adapter/pagination-adapter.ts @@ -1,21 +1,37 @@ -/** - * Slice the requested hits based on the pagination position. - * - * @param {Record, - page: number, +import type { + MeiliSearchResponse, + PaginationState, + InstantSearchPagination, +} from '../../types' + +function adaptNbPages( + searchResponse: MeiliSearchResponse>, hitsPerPage: number -): Array> { - if (hitsPerPage < 0) { - throw new TypeError( - 'Value too small for "hitsPerPage" parameter, expected integer between 0 and 9223372036854775807' - ) +): number { + if (searchResponse.totalPages != null) { + return searchResponse.totalPages + } + // Avoid dividing by 0 + if (hitsPerPage === 0) { + return 0 + } + + const { limit = 20, offset = 0, hits } = searchResponse + const additionalPage = hits.length >= limit ? 1 : 0 + + return offset / hitsPerPage + 1 + additionalPage +} + +export function adaptPaginationParameters( + searchResponse: MeiliSearchResponse>, + paginationState: PaginationState +): InstantSearchPagination & { nbPages: number } { + const { hitsPerPage, page } = paginationState + const nbPages = adaptNbPages(searchResponse, hitsPerPage) + + return { + page, + nbPages, + hitsPerPage, } - const start = page * hitsPerPage - return hits.slice(start, start + hitsPerPage) } diff --git a/src/adapter/search-response-adapter/search-response-adapter.ts b/src/adapter/search-response-adapter/search-response-adapter.ts index 57a25524..6fef2aef 100644 --- a/src/adapter/search-response-adapter/search-response-adapter.ts +++ b/src/adapter/search-response-adapter/search-response-adapter.ts @@ -3,16 +3,16 @@ import type { MeiliSearchResponse, AlgoliaSearchResponse, } from '../../types' -import { ceiledDivision } from '../../utils' import { adaptHits } from './hits-adapter' +import { adaptTotalHits } from './total-hits-adapter' +import { adaptPaginationParameters } from './pagination-adapter' /** * Adapt search response from Meilisearch * to search response compliant with instantsearch.js * - * @param {MeiliSearchResponse>} searchResponse * @param {SearchContext} searchContext - * @param {PaginationContext} paginationContext * @returns {{ results: Array> }} */ export function adaptSearchResponse( @@ -20,21 +20,15 @@ export function adaptSearchResponse( searchContext: SearchContext ): { results: Array> } { const searchResponseOptionals: Record = {} + const { processingTimeMs, query, facetDistribution: facets } = searchResponse - const facets = searchResponse.facetDistribution - const { pagination } = searchContext - - const nbPages = ceiledDivision( - searchResponse.hits.length, - pagination.hitsPerPage + const { hitsPerPage, page, nbPages } = adaptPaginationParameters( + searchResponse, + searchContext.pagination ) - const hits = adaptHits(searchResponse.hits, searchContext, pagination) - - const estimatedTotalHits = searchResponse.estimatedTotalHits - const processingTimeMs = searchResponse.processingTimeMs - const query = searchResponse.query - const { hitsPerPage, page } = pagination + const hits = adaptHits(searchResponse, searchContext) + const nbHits = adaptTotalHits(searchResponse) // Create response object compliant with InstantSearch const adaptedSearchResponse = { @@ -43,7 +37,7 @@ export function adaptSearchResponse( page, facets, nbPages, - nbHits: estimatedTotalHits, + nbHits, processingTimeMS: processingTimeMs, query, hits, diff --git a/src/adapter/search-response-adapter/total-hits-adapter.ts b/src/adapter/search-response-adapter/total-hits-adapter.ts new file mode 100644 index 00000000..b1a8ee2d --- /dev/null +++ b/src/adapter/search-response-adapter/total-hits-adapter.ts @@ -0,0 +1,20 @@ +import type { MeiliSearchResponse } from '../../types' + +export function adaptTotalHits( + searchResponse: MeiliSearchResponse> +): number { + const { + hitsPerPage = 0, + totalPages = 0, + estimatedTotalHits, + totalHits, + } = searchResponse + if (estimatedTotalHits != null) { + return estimatedTotalHits + } else if (totalHits != null) { + return totalHits + } + + // Should not happen but safeguarding just in case + return hitsPerPage * totalPages +} diff --git a/src/cache/first-facets-distribution.ts b/src/cache/first-facets-distribution.ts index 13cff973..3af0e29d 100644 --- a/src/cache/first-facets-distribution.ts +++ b/src/cache/first-facets-distribution.ts @@ -9,9 +9,6 @@ export async function cacheFirstFacetDistribution( ...searchContext, // placeholdersearch true to ensure a request is made placeholderSearch: true, - // Set paginationTotalHits to ensure limit is set to 0 - // in order to retrieve 0 documents during the default search request - pagination: { ...searchContext.pagination, paginationTotalHits: 0 }, // query set to empty to ensure retrieving the default facetdistribution query: '', } diff --git a/src/contexts/pagination-context.ts b/src/contexts/pagination-context.ts index c6d6b32c..40704665 100644 --- a/src/contexts/pagination-context.ts +++ b/src/contexts/pagination-context.ts @@ -1,20 +1,21 @@ -import { PaginationContext, PaginationParams } from '../types' +import { PaginationState } from '../types' /** - * @param {AlgoliaMultipleQueriesQuery} searchRequest - * @param {Context} options + * Create the current state of the pagination + * + * @param {boolean} [finite] + * @param {number} [hitsPerPage] + * @param {number} [page] * @returns {SearchContext} */ -export function createPaginationContext({ - paginationTotalHits, - hitsPerPage, - page, -}: PaginationParams): // searchContext: SearchContext -PaginationContext { +export function createPaginationState( + finite?: boolean, + hitsPerPage?: number, + page?: number +): PaginationState { return { - paginationTotalHits: - paginationTotalHits != null ? paginationTotalHits : 200, hitsPerPage: hitsPerPage === undefined ? 20 : hitsPerPage, // 20 is the Meilisearch's default limit value. `hitsPerPage` can be changed with `InsantSearch.configure`. page: page || 0, // default page is 0 if none is provided + finite: !!finite, } } diff --git a/src/contexts/search-context.ts b/src/contexts/search-context.ts index 8ed759c8..08548d4b 100644 --- a/src/contexts/search-context.ts +++ b/src/contexts/search-context.ts @@ -3,10 +3,9 @@ import { AlgoliaMultipleQueriesQuery, SearchContext, FacetDistribution, - MatchingStrategies, } from '../types' -import { createPaginationContext } from './pagination-context' +import { createPaginationState } from './pagination-context' /** * @param {AlgoliaMultipleQueriesQuery} searchRequest @@ -22,22 +21,21 @@ export function createSearchContext( const [indexUid, ...sortByArray] = searchRequest.indexName.split(':') const { params: instantSearchParams } = searchRequest - const pagination = createPaginationContext({ - paginationTotalHits: options.paginationTotalHits, - hitsPerPage: instantSearchParams?.hitsPerPage, // 20 by default - page: instantSearchParams?.page, - }) + const paginationState = createPaginationState( + options.finitePagination, + instantSearchParams?.hitsPerPage, + instantSearchParams?.page + ) const searchContext: SearchContext = { ...options, ...instantSearchParams, sort: sortByArray.join(':') || '', indexUid, - pagination, + pagination: paginationState, defaultFacetDistribution: defaultFacetDistribution || {}, placeholderSearch: options.placeholderSearch !== false, // true by default keepZeroFacets: !!options.keepZeroFacets, // false by default - finitePagination: !!options.finitePagination, // false by default } return searchContext } diff --git a/src/types/types.ts b/src/types/types.ts index 972acba2..ebd7b890 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -33,13 +33,12 @@ export const enum MatchingStrategies { } export type InstantMeiliSearchOptions = { - paginationTotalHits?: number placeholderSearch?: boolean primaryKey?: string keepZeroFacets?: boolean - finitePagination?: boolean clientAgents?: string[] matchingStrategy?: MatchingStrategies + finitePagination?: boolean } export type SearchCacheInterface = { @@ -61,23 +60,23 @@ export type GeoSearchContext = { insidePolygon?: ReadonlyArray } -export type PaginationContext = { - paginationTotalHits: number +// Current state of the pagination +export type PaginationState = { + finite: boolean hitsPerPage: number page: number } -export type PaginationParams = { - paginationTotalHits?: number - hitsPerPage?: number - page?: number +export type InstantSearchPagination = { + hitsPerPage: number + page: number + nbPages: number } export type SearchContext = Omit & InstantSearchParams & { defaultFacetDistribution: FacetDistribution - pagination: PaginationContext - finitePagination: boolean + pagination: PaginationState indexUid: string insideBoundingBox?: InsideBoundingBox keepZeroFacets?: boolean diff --git a/src/utils/index.ts b/src/utils/index.ts index 43ef3313..d05bae6f 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,5 +1,4 @@ export * from './array' export * from './string' -export * from './number' export * from './object' export * from './validate' diff --git a/src/utils/number.ts b/tests/assets/number.ts similarity index 100% rename from src/utils/number.ts rename to tests/assets/number.ts diff --git a/tests/env/react/src/App.js b/tests/env/react/src/App.js index e992c6f7..295beb62 100644 --- a/tests/env/react/src/App.js +++ b/tests/env/react/src/App.js @@ -17,7 +17,6 @@ import './App.css' import { instantMeiliSearch } from '../../../../src/index' const searchClient = instantMeiliSearch('http://localhost:7700', 'masterKey', { - paginationTotalHits: 60, primaryKey: 'id', }) diff --git a/tests/pagination.tests.ts b/tests/pagination.tests.ts index 71c879e2..8bbffa76 100644 --- a/tests/pagination.tests.ts +++ b/tests/pagination.tests.ts @@ -1,4 +1,3 @@ -import { instantMeiliSearch } from '../src' import { searchClient, dataset, @@ -99,100 +98,4 @@ describe('Pagination browser test', () => { expect(hits.length).toBe(0) expect(hits).toEqual([]) }) - - test('pagination total hits ', async () => { - const customClient = instantMeiliSearch( - 'http://localhost:7700', - 'masterKey', - { - paginationTotalHits: 1, - } - ) - - const response = await customClient.search([ - { - indexName: 'movies', - }, - ]) - - const hits = response.results[0].hits - expect(hits.length).toBe(1) - }) - - test('zero pagination total hits ', async () => { - const customClient = instantMeiliSearch( - 'http://localhost:7700', - 'masterKey', - { - paginationTotalHits: 0, - } - ) - - const response = await customClient.search([ - { - indexName: 'movies', - }, - ]) - - const hits = response.results[0].hits - expect(hits.length).toBe(0) - }) - - test('bigger pagination total hits than nbr hits', async () => { - const customClient = instantMeiliSearch( - 'http://localhost:7700', - 'masterKey', - { - paginationTotalHits: 1000, - } - ) - - const response = await customClient.search([ - { - indexName: 'movies', - }, - ]) - - const hits = response.results[0].hits - expect(hits.length).toBe(6) - }) - - test('bigger pagination total hits than nbr hits', async () => { - const customClient = instantMeiliSearch( - 'http://localhost:7700', - 'masterKey', - { - paginationTotalHits: 1000, - } - ) - - const response = await customClient.search([ - { - indexName: 'movies', - }, - ]) - - const hits = response.results[0].hits - expect(hits.length).toBe(6) - }) - - test('pagination total hits with finite pagination', async () => { - const customClient = instantMeiliSearch( - 'http://localhost:7700', - 'masterKey', - { - paginationTotalHits: 5, - finitePagination: true, - } - ) - - const response = await customClient.search([ - { - indexName: 'movies', - }, - ]) - - const hits = response.results[0].hits - expect(hits.length).toBe(5) - }) }) diff --git a/tests/placeholder-search.tests.ts b/tests/placeholder-search.tests.ts index b2c9acbd..16c4dac4 100644 --- a/tests/placeholder-search.tests.ts +++ b/tests/placeholder-search.tests.ts @@ -1,10 +1,5 @@ import { instantMeiliSearch } from '../src' -import { - searchClient, - dataset, - Movies, - meilisearchClient, -} from './assets/utils' +import { dataset, Movies, meilisearchClient } from './assets/utils' describe('Pagination browser test', () => { beforeAll(async () => { @@ -19,12 +14,11 @@ describe('Pagination browser test', () => { await meilisearchClient.index('movies').waitForTask(documentsTask.taskUid) }) - test('placeholdersearch set to false', async () => { + test('placeholdersearch set to true', async () => { const customClient = instantMeiliSearch( 'http://localhost:7700', 'masterKey', { - paginationTotalHits: 5, placeholderSearch: true, } ) @@ -36,15 +30,14 @@ describe('Pagination browser test', () => { ]) const hits = response.results[0].hits - expect(hits.length).toBe(5) + expect(hits.length).toBe(6) }) - test('placeholdersearch set to true', async () => { + test('placeholdersearch set to false', async () => { const customClient = instantMeiliSearch( 'http://localhost:7700', 'masterKey', { - paginationTotalHits: 5, placeholderSearch: false, } )