Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 62 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ NB: If you don't have any Meilisearch instance running and containing your data,

- [🔧 Installation](#-installation)
- [🎬 Usage](#-usage)
- [💅 Customization](#-customization)
- [⚡️ Example with InstantSearch](#-example-with-instantSearch)
- [🤖 Compatibility with Meilisearch and InstantSearch](#-compatibility-with-meilisearch-and-instantsearch)
- [📜 API Resources](#-api-resources)
Expand Down Expand Up @@ -68,13 +69,16 @@ To be able to create a search interface, you'll need to [install `instantsearch.
import { instantMeiliSearch } from '@meilisearch/instant-meilisearch'

const searchClient = instantMeiliSearch(
'https://integration-demos.meilisearch.com',
'q7QHwGiX841a509c8b05ef29e55f2d94c02c00635f729ccf097a734cbdf7961530f47c47'
'https://integration-demos.meilisearch.com', // Host
'q7QHwGiX841a509c8b05ef29e55f2d94c02c00635f729ccf097a734cbdf7961530f47c47' // API key
)
```

### Customization
## 💅 Customization

InstantMeilisearch offers some options you can set to further fit your needs.

The options are added as the third parameter of the `instantMeilisearch` function
```js
import { instantMeiliSearch } from '@meilisearch/instant-meilisearch'

Expand All @@ -85,18 +89,65 @@ const searchClient = instantMeiliSearch(
paginationTotalHits: 30, // default: 200.
placeholderSearch: false, // default: true.
primaryKey: 'id', // default: undefined
// ...
}
)
```

- `placeholderSearch` (`true` by default). Displays documents even when the query is empty.
### Placeholder Search

Placeholders search means showing results even when the search query is empty. By default it is `true`.
When placeholder search is set to `false`, no results appears when searching on no characters. For example, if the query is "" no results appear.

```js
{ placeholderSearch : true } // default true
```

### Pagination total hits

The total (and finite) number of hits you can browse during pagination when using the [pagination widget](https://www.algolia.com/doc/api-reference/widgets/pagination/js/). If the pagination widget is not used, `paginationTotalHits` is ignored.<br>

Which means that, with a `paginationTotalHits` default value of 200, and `hitsPerPage` default value of 20, you can browse `paginationTotalHits / hitsPerPage` => `200 / 20 = 10` pages during pagination. Each of the 10 pages containing 20 results.<br>

The default value of `hitsPerPage` is set to `20` but it can be changed with [`InsantSearch.configure`](https://www.algolia.com/doc/api-reference/widgets/configure/js/#examples).<br>

```js
{ paginationTotalHits : 20 } // default: 200
```

⚠️ Meilisearch is not designed for pagination and this can lead to performances issues, so the usage of the pagination widget is not encouraged. However, the `paginationTotalHits` parameter lets you implement this pagination with less performance issue as possible: depending on your dataset (the size of each document and the number of documents) you might decrease the value of `paginationTotalHits`.<br>
More information about Meilisearch and the pagination [here](https://github.com/meilisearch/documentation/issues/561).

### Primary key

Specify the field in your documents containing the [unique identifier](https://docs.meilisearch.com/learn/core_concepts/documents.html#primary-field) (`undefined` by default). By adding this option, we avoid instantSearch errors that are thrown in the browser console. In `React` particularly, this option removes the `Each child in a list should have a unique "key" prop` error.

```js
{ primaryKey : 'id' } // default: undefined
```

### keepZeroFacets

- `paginationTotalHits` (`200` by default): The total (and finite) number of hits you can browse during pagination when using the [pagination widget](https://www.algolia.com/doc/api-reference/widgets/pagination/js/). If the pagination widget is not used, `paginationTotalHits` is ignored.<br>
Which means that, with a `paginationTotalHits` default value of 200, and `hitsPerPage` default value of 20, you can browse `paginationTotalHits / hitsPerPage` => `200 / 20 = 10` pages during pagination. Each of the 10 pages containing 20 results.<br>
The default value of `hitsPerPage` is set to `20` but it can be changed with [`InsantSearch.configure`](https://www.algolia.com/doc/api-reference/widgets/configure/js/#examples).<br>
⚠️ Meilisearch is not designed for pagination and this can lead to performances issues, so the usage of the pagination widget is not encouraged. However, the `paginationTotalHits` parameter lets you implement this pagination with less performance issue as possible: depending on your dataset (the size of each document and the number of documents) you might decrease the value of `paginationTotalHits`.<br>
More information about Meilisearch and the pagination [here](https://github.com/meilisearch/documentation/issues/561).
- `primaryKey` (`undefined` by default): Specify the field in your documents containing the [unique identifier](https://docs.meilisearch.com/learn/core_concepts/documents.html#primary-field). By adding this option, we avoid instantSearch errors that are thrown in the browser console. In `React` particularly, this option removes the `Each child in a list should have a unique "key" prop` error.
`keepZeroFacets` set to `true` keeps the facets even when they have 0 matching documents (default `false`).

When using `refinementList` it happens that by checking some facets, the ones with no more valid documents disapear.
Nonetheless you might want to still showcase them even if they have 0 matched documents with the current request:

Without `keepZeroFacets` set to `true`:
genres:
- [x] horror (2000)
- [x] thriller (214)
- [ ] comedy (0)

With `keepZeroFacets` set to `false`, `comedy` disapears:

genres:
- [x] horror (2000)
- [x] thriller (214)

```js
{ keepZeroFacets : true } // default: false
```

## Example with InstantSearch

Expand Down Expand Up @@ -584,7 +635,7 @@ The `refinementList` widget is one of the most common widgets you can find in a

- ✅ container: The CSS Selector or HTMLElement to insert the refinements. _required_
- ✅ attribute: The facet to display _required_
- ✅ operator: How to apply facets, `and` or `or` (`and` is the default value).
- ✅ operator: How to apply facets, `and` or `or` (`and` is the default value). ⚠️ Does not seem to work on react-instantsearch.
- ✅ limit: How many facet values to retrieve.
- ✅ showMore: Whether to display a button that expands the number of items.
- ✅ showMoreLimit: The maximum number of displayed items. Does not work when showMoreLimit > limit.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,20 +1,20 @@
import { assignMissingFilters } from '../filters'
import { addMissingFacets } from '../filters'

test('One field in cache present in distribution', () => {
const returnedDistribution = assignMissingFilters(
const returnedDistribution = addMissingFacets(
{ genre: ['comedy'] },
{ genre: { comedy: 1 } }
)
expect(returnedDistribution).toMatchObject({ genre: { comedy: 1 } })
})

test('One field in cache not present in distribution', () => {
const returnedDistribution = assignMissingFilters({ genre: ['comedy'] }, {})
const returnedDistribution = addMissingFacets({ genre: ['comedy'] }, {})
expect(returnedDistribution).toMatchObject({ genre: { comedy: 0 } })
})

test('two field in cache only one present in distribution', () => {
const returnedDistribution = assignMissingFilters(
const returnedDistribution = addMissingFacets(
{ genre: ['comedy'], title: ['hamlet'] },
{ genre: { comedy: 12 } }
)
Expand All @@ -25,7 +25,7 @@ test('two field in cache only one present in distribution', () => {
})

test('two field in cache w/ different facet name none present in distribution', () => {
const returnedDistribution = assignMissingFilters(
const returnedDistribution = addMissingFacets(
{ genre: ['comedy'], title: ['hamlet'] },
{}
)
Expand All @@ -36,7 +36,7 @@ test('two field in cache w/ different facet name none present in distribution',
})

test('two field in cache w/ different facet name both present in distribution', () => {
const returnedDistribution = assignMissingFilters(
const returnedDistribution = addMissingFacets(
{ genre: ['comedy'], title: ['hamlet'] },
{ genre: { comedy: 12 }, title: { hamlet: 1 } }
)
Expand All @@ -47,7 +47,7 @@ test('two field in cache w/ different facet name both present in distribution',
})

test('Three field in cache w/ different facet name two present in distribution', () => {
const returnedDistribution = assignMissingFilters(
const returnedDistribution = addMissingFacets(
{ genre: ['comedy', 'horror'], title: ['hamlet'] },
{ genre: { comedy: 12 }, title: { hamlet: 1 } }
)
Expand All @@ -58,31 +58,28 @@ test('Three field in cache w/ different facet name two present in distribution',
})

test('Cache is undefined and facets distribution is not', () => {
const returnedDistribution = assignMissingFilters(undefined, {
const returnedDistribution = addMissingFacets(undefined, {
genre: { comedy: 12 },
})
expect(returnedDistribution).toMatchObject({ genre: { comedy: 12 } })
})

test('Cache is empty object and facets distribution is not', () => {
const returnedDistribution = assignMissingFilters(
{},
{ genre: { comedy: 12 } }
)
const returnedDistribution = addMissingFacets({}, { genre: { comedy: 12 } })
expect(returnedDistribution).toMatchObject({ genre: { comedy: 12 } })
})

test('Cache is empty object and facets distribution empty object', () => {
const returnedDistribution = assignMissingFilters({}, {})
const returnedDistribution = addMissingFacets({}, {})
expect(returnedDistribution).toMatchObject({})
})

test('Cache is undefined and facets distribution empty object', () => {
const returnedDistribution = assignMissingFilters(undefined, {})
const returnedDistribution = addMissingFacets(undefined, {})
expect(returnedDistribution).toMatchObject({})
})

test('Cache is undefined and facets distribution is undefined', () => {
const returnedDistribution = assignMissingFilters(undefined, undefined)
const returnedDistribution = addMissingFacets(undefined, undefined)
expect(returnedDistribution).toMatchObject({})
})
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { cacheFilters } from '../filters'
import { extractFacets } from '../filters'

const facetCacheData = [
{
Expand Down Expand Up @@ -59,7 +59,11 @@ describe.each(facetCacheData)(
'Facet cache tests',
({ filters, expectedCache, cacheTestTitle }) => {
it(cacheTestTitle, () => {
const cache = cacheFilters(filters)
const cache = extractFacets(
// @ts-ignore ignore to avoid having to add all the searchContext
{ keepZeroFacets: false, defaultFacetDistribution: {} },
{ filter: filters }
)
expect(cache).toEqual(expectedCache)
})
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ test('Adapt basic SearchContext ', () => {
const searchParams = adaptSearchParams({
indexUid: 'test',
paginationTotalHits: 20,
defaultFacetDistribution: {},
})
expect(searchParams.attributesToHighlight).toContain('*')
expect(searchParams.attributesToHighlight?.length).toBe(1)
Expand All @@ -15,6 +16,7 @@ test('Adapt SearchContext with filters, sort and no geo rules ', () => {
paginationTotalHits: 20,
facetFilters: [['genres:Drama', 'genres:Thriller'], ['title:Ariel']],
sort: 'id < 1',
defaultFacetDistribution: {},
})

expect(searchParams.filter).toStrictEqual([
Expand All @@ -33,6 +35,7 @@ test('Adapt SearchContext with filters, sort and geo rules ', () => {
facetFilters: [['genres:Drama', 'genres:Thriller'], ['title:Ariel']],
insideBoundingBox: '0,0,0,0',
sort: 'id < 1',
defaultFacetDistribution: {},
})

expect(searchParams.filter).toStrictEqual([
Expand All @@ -51,6 +54,7 @@ test('Adapt SearchContext with only facetFilters and geo rules ', () => {
paginationTotalHits: 20,
facetFilters: [['genres:Drama', 'genres:Thriller'], ['title:Ariel']],
insideBoundingBox: '0,0,0,0',
defaultFacetDistribution: {},
})

expect(searchParams.filter).toEqual([
Expand All @@ -68,6 +72,7 @@ test('Adapt SearchContext with only sort and geo rules ', () => {
paginationTotalHits: 20,
insideBoundingBox: '0,0,0,0',
sort: 'id < 1',
defaultFacetDistribution: {},
})

expect(searchParams.filter).toEqual(['_geoRadius(0.00000, 0.00000, 0)'])
Expand All @@ -76,11 +81,12 @@ test('Adapt SearchContext with only sort and geo rules ', () => {
expect(searchParams.attributesToHighlight?.length).toBe(1)
})

test('Adapt SearchContext with no sort abd no filters and geo rules ', () => {
test('Adapt SearchContext with no sort and no filters and geo rules ', () => {
const searchParams = adaptSearchParams({
indexUid: 'test',
paginationTotalHits: 20,
insideBoundingBox: '0,0,0,0',
defaultFacetDistribution: {},
})

expect(searchParams.filter).toEqual(['_geoRadius(0.00000, 0.00000, 0)'])
Expand Down
56 changes: 44 additions & 12 deletions src/adapter/search-request-adapter/filters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import {
Filter,
ParsedFilter,
FacetsDistribution,
FilterCache,
FacetsCache,
MeiliSearchParams,
SearchContext,
} from '../../types'
import { removeUndefined } from '../../utils'

Expand Down Expand Up @@ -40,12 +42,12 @@ function extractFilters(filters?: Filter): Array<ParsedFilter | undefined> {

/**
* @param {Filter} filters?
* @returns {FilterCache}
* @returns {FacetsCache}
*/
export function cacheFilters(filters?: Filter): FilterCache {
export function getFacetsFromFilter(filters?: Filter): FacetsCache {
const extractedFilters = extractFilters(filters)
const cleanFilters = removeUndefined(extractedFilters)
return cleanFilters.reduce<FilterCache>(
return cleanFilters.reduce<FacetsCache>(
(cache, parsedFilter: ParsedFilter) => {
const { filterName, value } = parsedFilter
const prevFields = cache[filterName] || []
Expand All @@ -55,34 +57,63 @@ export function cacheFilters(filters?: Filter): FilterCache {
}
return cache
},
{} as FilterCache
{} as FacetsCache
)
}

function getFacetsFromDefaultDistribution(
facetsDistribution: FacetsDistribution
): FacetsCache {
return Object.keys(facetsDistribution).reduce((cache: any, facet) => {
const facetValues = Object.keys(facetsDistribution[facet])
return {
...cache,
[facet]: facetValues,
}
}, {})
}

/**
* @param {Filter} filters?
* @returns {FacetsCache}
*/
export function extractFacets(
searchContext: SearchContext,
searchParams: MeiliSearchParams
): FacetsCache {
if (searchContext.keepZeroFacets) {
return getFacetsFromDefaultDistribution(
searchContext.defaultFacetDistribution
)
} else {
return getFacetsFromFilter(searchParams?.filter)
}
}

/**
* Assign missing filters to facetsDistribution.
* All facet passed as filter should appear in the facetsDistribution.
* If not present, the facet is added with 0 as value.
*
*
* @param {FilterCache} cache?
* @param {FacetsCache} cache?
* @param {FacetsDistribution} distribution?
* @returns {FacetsDistribution}
*/
export function assignMissingFilters(
cachedFilters?: FilterCache,
export function addMissingFacets(
cachedFacets?: FacetsCache,
distribution?: FacetsDistribution
): FacetsDistribution {
distribution = distribution || {}

// If cachedFilters contains something
if (cachedFilters && Object.keys(cachedFilters).length > 0) {
// If cachedFacets contains something
if (cachedFacets && Object.keys(cachedFacets).length > 0) {
// for all filters in cached filters
for (const cachedFacet in cachedFilters) {
for (const cachedFacet in cachedFacets) {
// if facet does not exist on returned distribution, add an empty object
if (!distribution[cachedFacet]) distribution[cachedFacet] = {}
// for all fields in every filter
for (const cachedField of cachedFilters[cachedFacet]) {
for (const cachedField of cachedFacets[cachedFacet]) {
// if the field is not present in the returned distribution
// set it at 0
if (!Object.keys(distribution[cachedFacet]).includes(cachedField)) {
Expand All @@ -92,5 +123,6 @@ export function assignMissingFilters(
}
}
}

return distribution
}
Loading