Skip to content

Commit 2da557d

Browse files
Merge #695
695: Add possibility to show facets with 0 matching documents r=bidoubiwa a=bidoubiwa fixes: #599 Some improvements were done on the readme as well <img width="261" alt="Screenshot 2022-03-17 at 18 16 54" src="https://user-images.githubusercontent.com/33010418/158858603-b151f2a2-7c2b-4dea-a3ed-c7fb3148bea2.png"> Co-authored-by: Charlotte Vermandel <[email protected]> Co-authored-by: cvermand <[email protected]>
2 parents 1c6c0f0 + 2f812bd commit 2da557d

File tree

13 files changed

+229
-117
lines changed

13 files changed

+229
-117
lines changed

README.md

Lines changed: 62 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ NB: If you don't have any Meilisearch instance running and containing your data,
4040

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

7071
const searchClient = instantMeiliSearch(
71-
'https://integration-demos.meilisearch.com',
72-
'q7QHwGiX841a509c8b05ef29e55f2d94c02c00635f729ccf097a734cbdf7961530f47c47'
72+
'https://integration-demos.meilisearch.com', // Host
73+
'q7QHwGiX841a509c8b05ef29e55f2d94c02c00635f729ccf097a734cbdf7961530f47c47' // API key
7374
)
7475
```
7576

76-
### Customization
77+
## 💅 Customization
78+
79+
InstantMeilisearch offers some options you can set to further fit your needs.
7780

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

@@ -85,18 +89,65 @@ const searchClient = instantMeiliSearch(
8589
paginationTotalHits: 30, // default: 200.
8690
placeholderSearch: false, // default: true.
8791
primaryKey: 'id', // default: undefined
92+
// ...
8893
}
8994
)
9095
```
9196

92-
- `placeholderSearch` (`true` by default). Displays documents even when the query is empty.
97+
### Placeholder Search
98+
99+
Placeholders search means showing results even when the search query is empty. By default it is `true`.
100+
When placeholder search is set to `false`, no results appears when searching on no characters. For example, if the query is "" no results appear.
101+
102+
```js
103+
{ placeholderSearch : true } // default true
104+
```
105+
106+
### Pagination total hits
107+
108+
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>
109+
110+
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>
111+
112+
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>
113+
114+
```js
115+
{ paginationTotalHits : 20 } // default: 200
116+
```
117+
118+
⚠️ 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>
119+
More information about Meilisearch and the pagination [here](https://github.com/meilisearch/documentation/issues/561).
120+
121+
### Primary key
122+
123+
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.
124+
125+
```js
126+
{ primaryKey : 'id' } // default: undefined
127+
```
128+
129+
### keepZeroFacets
93130

94-
- `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>
95-
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>
96-
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>
97-
⚠️ 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>
98-
More information about Meilisearch and the pagination [here](https://github.com/meilisearch/documentation/issues/561).
99-
- `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.
131+
`keepZeroFacets` set to `true` keeps the facets even when they have 0 matching documents (default `false`).
132+
133+
When using `refinementList` it happens that by checking some facets, the ones with no more valid documents disapear.
134+
Nonetheless you might want to still showcase them even if they have 0 matched documents with the current request:
135+
136+
Without `keepZeroFacets` set to `true`:
137+
genres:
138+
- [x] horror (2000)
139+
- [x] thriller (214)
140+
- [ ] comedy (0)
141+
142+
With `keepZeroFacets` set to `false`, `comedy` disapears:
143+
144+
genres:
145+
- [x] horror (2000)
146+
- [x] thriller (214)
147+
148+
```js
149+
{ keepZeroFacets : true } // default: false
150+
```
100151

101152
## Example with InstantSearch
102153

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

585636
- ✅ container: The CSS Selector or HTMLElement to insert the refinements. _required_
586637
- ✅ attribute: The facet to display _required_
587-
- ✅ operator: How to apply facets, `and` or `or` (`and` is the default value).
638+
- ✅ operator: How to apply facets, `and` or `or` (`and` is the default value). ⚠️ Does not seem to work on react-instantsearch.
588639
- ✅ limit: How many facet values to retrieve.
589640
- ✅ showMore: Whether to display a button that expands the number of items.
590641
- ✅ showMoreLimit: The maximum number of displayed items. Does not work when showMoreLimit > limit.
Lines changed: 12 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,20 @@
1-
import { assignMissingFilters } from '../filters'
1+
import { addMissingFacets } from '../filters'
22

33
test('One field in cache present in distribution', () => {
4-
const returnedDistribution = assignMissingFilters(
4+
const returnedDistribution = addMissingFacets(
55
{ genre: ['comedy'] },
66
{ genre: { comedy: 1 } }
77
)
88
expect(returnedDistribution).toMatchObject({ genre: { comedy: 1 } })
99
})
1010

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

1616
test('two field in cache only one present in distribution', () => {
17-
const returnedDistribution = assignMissingFilters(
17+
const returnedDistribution = addMissingFacets(
1818
{ genre: ['comedy'], title: ['hamlet'] },
1919
{ genre: { comedy: 12 } }
2020
)
@@ -25,7 +25,7 @@ test('two field in cache only one present in distribution', () => {
2525
})
2626

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

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

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

6060
test('Cache is undefined and facets distribution is not', () => {
61-
const returnedDistribution = assignMissingFilters(undefined, {
61+
const returnedDistribution = addMissingFacets(undefined, {
6262
genre: { comedy: 12 },
6363
})
6464
expect(returnedDistribution).toMatchObject({ genre: { comedy: 12 } })
6565
})
6666

6767
test('Cache is empty object and facets distribution is not', () => {
68-
const returnedDistribution = assignMissingFilters(
69-
{},
70-
{ genre: { comedy: 12 } }
71-
)
68+
const returnedDistribution = addMissingFacets({}, { genre: { comedy: 12 } })
7269
expect(returnedDistribution).toMatchObject({ genre: { comedy: 12 } })
7370
})
7471

7572
test('Cache is empty object and facets distribution empty object', () => {
76-
const returnedDistribution = assignMissingFilters({}, {})
73+
const returnedDistribution = addMissingFacets({}, {})
7774
expect(returnedDistribution).toMatchObject({})
7875
})
7976

8077
test('Cache is undefined and facets distribution empty object', () => {
81-
const returnedDistribution = assignMissingFilters(undefined, {})
78+
const returnedDistribution = addMissingFacets(undefined, {})
8279
expect(returnedDistribution).toMatchObject({})
8380
})
8481

8582
test('Cache is undefined and facets distribution is undefined', () => {
86-
const returnedDistribution = assignMissingFilters(undefined, undefined)
83+
const returnedDistribution = addMissingFacets(undefined, undefined)
8784
expect(returnedDistribution).toMatchObject({})
8885
})

src/adapter/search-request-adapter/__tests__/filter-cache.tests.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { cacheFilters } from '../filters'
1+
import { extractFacets } from '../filters'
22

33
const facetCacheData = [
44
{
@@ -59,7 +59,11 @@ describe.each(facetCacheData)(
5959
'Facet cache tests',
6060
({ filters, expectedCache, cacheTestTitle }) => {
6161
it(cacheTestTitle, () => {
62-
const cache = cacheFilters(filters)
62+
const cache = extractFacets(
63+
// @ts-ignore ignore to avoid having to add all the searchContext
64+
{ keepZeroFacets: false, defaultFacetDistribution: {} },
65+
{ filter: filters }
66+
)
6367
expect(cache).toEqual(expectedCache)
6468
})
6569
}

src/adapter/search-request-adapter/__tests__/search-params.tests.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ test('Adapt basic SearchContext ', () => {
44
const searchParams = adaptSearchParams({
55
indexUid: 'test',
66
paginationTotalHits: 20,
7+
defaultFacetDistribution: {},
78
})
89
expect(searchParams.attributesToHighlight).toContain('*')
910
expect(searchParams.attributesToHighlight?.length).toBe(1)
@@ -15,6 +16,7 @@ test('Adapt SearchContext with filters, sort and no geo rules ', () => {
1516
paginationTotalHits: 20,
1617
facetFilters: [['genres:Drama', 'genres:Thriller'], ['title:Ariel']],
1718
sort: 'id < 1',
19+
defaultFacetDistribution: {},
1820
})
1921

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

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

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

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

79-
test('Adapt SearchContext with no sort abd no filters and geo rules ', () => {
84+
test('Adapt SearchContext with no sort and no filters and geo rules ', () => {
8085
const searchParams = adaptSearchParams({
8186
indexUid: 'test',
8287
paginationTotalHits: 20,
8388
insideBoundingBox: '0,0,0,0',
89+
defaultFacetDistribution: {},
8490
})
8591

8692
expect(searchParams.filter).toEqual(['_geoRadius(0.00000, 0.00000, 0)'])

src/adapter/search-request-adapter/filters.ts

Lines changed: 44 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ import {
22
Filter,
33
ParsedFilter,
44
FacetsDistribution,
5-
FilterCache,
5+
FacetsCache,
6+
MeiliSearchParams,
7+
SearchContext,
68
} from '../../types'
79
import { removeUndefined } from '../../utils'
810

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

4143
/**
4244
* @param {Filter} filters?
43-
* @returns {FilterCache}
45+
* @returns {FacetsCache}
4446
*/
45-
export function cacheFilters(filters?: Filter): FilterCache {
47+
export function getFacetsFromFilter(filters?: Filter): FacetsCache {
4648
const extractedFilters = extractFilters(filters)
4749
const cleanFilters = removeUndefined(extractedFilters)
48-
return cleanFilters.reduce<FilterCache>(
50+
return cleanFilters.reduce<FacetsCache>(
4951
(cache, parsedFilter: ParsedFilter) => {
5052
const { filterName, value } = parsedFilter
5153
const prevFields = cache[filterName] || []
@@ -55,34 +57,63 @@ export function cacheFilters(filters?: Filter): FilterCache {
5557
}
5658
return cache
5759
},
58-
{} as FilterCache
60+
{} as FacetsCache
5961
)
6062
}
6163

64+
function getFacetsFromDefaultDistribution(
65+
facetsDistribution: FacetsDistribution
66+
): FacetsCache {
67+
return Object.keys(facetsDistribution).reduce((cache: any, facet) => {
68+
const facetValues = Object.keys(facetsDistribution[facet])
69+
return {
70+
...cache,
71+
[facet]: facetValues,
72+
}
73+
}, {})
74+
}
75+
76+
/**
77+
* @param {Filter} filters?
78+
* @returns {FacetsCache}
79+
*/
80+
export function extractFacets(
81+
searchContext: SearchContext,
82+
searchParams: MeiliSearchParams
83+
): FacetsCache {
84+
if (searchContext.keepZeroFacets) {
85+
return getFacetsFromDefaultDistribution(
86+
searchContext.defaultFacetDistribution
87+
)
88+
} else {
89+
return getFacetsFromFilter(searchParams?.filter)
90+
}
91+
}
92+
6293
/**
6394
* Assign missing filters to facetsDistribution.
6495
* All facet passed as filter should appear in the facetsDistribution.
6596
* If not present, the facet is added with 0 as value.
6697
*
6798
*
68-
* @param {FilterCache} cache?
99+
* @param {FacetsCache} cache?
69100
* @param {FacetsDistribution} distribution?
70101
* @returns {FacetsDistribution}
71102
*/
72-
export function assignMissingFilters(
73-
cachedFilters?: FilterCache,
103+
export function addMissingFacets(
104+
cachedFacets?: FacetsCache,
74105
distribution?: FacetsDistribution
75106
): FacetsDistribution {
76107
distribution = distribution || {}
77108

78-
// If cachedFilters contains something
79-
if (cachedFilters && Object.keys(cachedFilters).length > 0) {
109+
// If cachedFacets contains something
110+
if (cachedFacets && Object.keys(cachedFacets).length > 0) {
80111
// for all filters in cached filters
81-
for (const cachedFacet in cachedFilters) {
112+
for (const cachedFacet in cachedFacets) {
82113
// if facet does not exist on returned distribution, add an empty object
83114
if (!distribution[cachedFacet]) distribution[cachedFacet] = {}
84115
// for all fields in every filter
85-
for (const cachedField of cachedFilters[cachedFacet]) {
116+
for (const cachedField of cachedFacets[cachedFacet]) {
86117
// if the field is not present in the returned distribution
87118
// set it at 0
88119
if (!Object.keys(distribution[cachedFacet]).includes(cachedField)) {
@@ -92,5 +123,6 @@ export function assignMissingFilters(
92123
}
93124
}
94125
}
126+
95127
return distribution
96128
}

0 commit comments

Comments
 (0)