diff --git a/.changeset/stupid-mirrors-itch.md b/.changeset/stupid-mirrors-itch.md new file mode 100644 index 00000000..1f42b708 --- /dev/null +++ b/.changeset/stupid-mirrors-itch.md @@ -0,0 +1,6 @@ +--- +"@meilisearch/instant-meilisearch": patch +--- + +Add the facetStats of numeric facets, giving access to the min and max value of these facets. +The following widgets are now compatible with Meilisearch: `RangeSlider` and `RangeInput` diff --git a/README.md b/README.md index 7e39b3f2..cbb8893d 100644 --- a/README.md +++ b/README.md @@ -741,54 +741,14 @@ The `rangeSlider` widget provides a user-friendly way to filter the results, bas - ✅ attribute: The name of the attribute in the document. _required_. - ✅ min: The minimum value for the input. _required_ - ✅ max: The maximum value for the input. _required_ -- ❌ precision: The number of digits after the decimal point to use. Not compatible as only integers work with `rangeSlider`. +- ✅ precision: The number of digits after the decimal point to use. Not compatible as only integers work with `rangeSlider`. - ✅ step: The number of steps between each handle move. - ✅ pips: Whether to show slider pips (ruler marks). - ✅ tooltips: Whether to show tooltips. The default tooltips show the raw value. - ✅ cssClasses: The CSS classes to override. -#### ⚠️ The component is compatible but only by applying the following requirements: +To be able to use the `rangeSlider` on an attribute, the attribute must be in the[`filterableAttributes`](https://docs.meilisearch.com/reference/features/filtering_and_faceted_search.html#configuring-filters) and must contain numeric values. -#### 1. Manual Min Max - -Min and max of attributes are not returned from Meilisearch and thus **must be set manually**. - -```js - instantsearch.widgets.rangeSlider({ - // ... - min: 0, - max: 100000, - }), -``` - -#### 2. Attribute must be in `filterableAttributes` - -If the attribute is not in the [`filterableAttributes`](https://docs.meilisearch.com/reference/features/filtering_and_faceted_search.html#configuring-filters) setting list, filtering on this attribute is not possible. - -Example: -Given the attribute `id` that has not been added in `filterableAttributes`: - -```js - instantsearch.widgets.rangeSlider({ - attribute: 'id', - // ... - }), -``` - -The widget throws the following error: - -```json -{ - "message": " .. attribute `id` is not filterable, available filterable attributes are: author, price, genres", - "errorCode": "bad_request", - "errorType": "invalid_request_error", - "errorLink": "https://docs.meilisearch.com/errors#bad_request" -} -``` - -To avoid this error, the attribute must be added to the [`filterableAttributes` setting](https://docs.meilisearch.com/reference/api/filterable_attributes.html#get-filterable-attributes). - -After these steps, `rangeSlider` becomes compatible. ### ✅ Menu @@ -832,7 +792,7 @@ The `rangeInput` widget allows a user to select a numeric range using a minimum - ✅ templates: The templates to use for the widget. - ✅ cssClasses: The CSS classes to override. -⚠️ Not compatible with Meilisearch by default, needs a workaround. See workaround in [RangeSlider](#-rangeslider) section. +To be able to use the `RangeInput` on an attribute, the attribute must be in the[`filterableAttributes`](https://docs.meilisearch.com/reference/features/filtering_and_faceted_search.html#configuring-filters) and must contain numeric values. ### ✅ MenuSelect diff --git a/packages/instant-meilisearch/__tests__/facet-stats.test.ts b/packages/instant-meilisearch/__tests__/facet-stats.test.ts new file mode 100644 index 00000000..7d366d1a --- /dev/null +++ b/packages/instant-meilisearch/__tests__/facet-stats.test.ts @@ -0,0 +1,68 @@ +import { searchClient, dataset, meilisearchClient } from './assets/utils' + +describe('Facet stats tests', () => { + beforeAll(async () => { + const deleteTask = await meilisearchClient.deleteIndex('movies') + await meilisearchClient.waitForTask(deleteTask.taskUid) + await meilisearchClient + .index('movies') + .updateFilterableAttributes(['genres', 'release_date', 'id']) + const documentsTask = await meilisearchClient + .index('movies') + .addDocuments(dataset) + await meilisearchClient.index('movies').waitForTask(documentsTask.taskUid) + }) + + test('Facet stats on an empty facets array', async () => { + const response = await searchClient.search([ + { + indexName: 'movies', + params: { + query: '', + facets: [], + }, + }, + ]) + + expect(response.results[0].facets_stats?.release_date).toEqual(undefined) + }) + + test('Facet stats on a facet with no numeric values', async () => { + const response = await searchClient.search([ + { + indexName: 'movies', + params: { + query: '', + facets: ['genres'], + }, + }, + ]) + + expect(response.results[0].facets_stats?.genres).toEqual(undefined) + }) + + test('Facet stats on two facet', async () => { + const response = await searchClient.search([ + { + indexName: 'movies', + params: { + query: '', + facets: ['release_date', 'id'], + }, + }, + ]) + + expect(response.results[0].facets_stats?.release_date).toEqual({ + avg: 0, + max: 1065744000, + min: 233366400, + sum: 0, + }) + expect(response.results[0].facets_stats?.id).toEqual({ + avg: 0, + max: 30, + min: 2, + sum: 0, + }) + }) +}) diff --git a/packages/instant-meilisearch/src/adapter/search-response-adapter/adapt-facet-stats.ts b/packages/instant-meilisearch/src/adapter/search-response-adapter/adapt-facet-stats.ts new file mode 100644 index 00000000..590ff816 --- /dev/null +++ b/packages/instant-meilisearch/src/adapter/search-response-adapter/adapt-facet-stats.ts @@ -0,0 +1,19 @@ +import { + AlgoliaSearchResponse, + MeiliFacetStats, + AlgoliaFacetStats, +} from '../../types' + +export function adaptFacetStats( + meiliFacetStats: MeiliFacetStats +): AlgoliaSearchResponse['facets_stats'] { + const facetStats = Object.keys(meiliFacetStats).reduce( + (stats: AlgoliaFacetStats, facet: string) => { + stats[facet] = { ...meiliFacetStats[facet], avg: 0, sum: 0 } // Set at 0 as these numbers are not provided by Meilisearch + + return stats + }, + {} as AlgoliaFacetStats + ) + return facetStats +} diff --git a/packages/instant-meilisearch/src/adapter/search-response-adapter/search-response-adapter.ts b/packages/instant-meilisearch/src/adapter/search-response-adapter/search-response-adapter.ts index 24304c6c..1c097dac 100644 --- a/packages/instant-meilisearch/src/adapter/search-response-adapter/search-response-adapter.ts +++ b/packages/instant-meilisearch/src/adapter/search-response-adapter/search-response-adapter.ts @@ -8,6 +8,7 @@ import { adaptHits } from './hits-adapter' import { adaptTotalHits } from './total-hits-adapter' import { adaptPaginationParameters } from './pagination-adapter' import { adaptFacetDistribution } from './facet-distribution-adapter' +import { adaptFacetStats } from './adapt-facet-stats' /** * Adapt multiple search results from Meilisearch @@ -54,6 +55,7 @@ export function adaptSearchResult( query, indexUid, facetDistribution: responseFacetDistribution = {}, + facetStats = {}, } = meiliSearchResult const facets = Object.keys(responseFacetDistribution) @@ -86,6 +88,7 @@ export function adaptSearchResult( hits, params: '', exhaustiveNbHits: false, + facets_stats: adaptFacetStats(facetStats), } return adaptedSearchResult } diff --git a/packages/instant-meilisearch/src/types/types.ts b/packages/instant-meilisearch/src/types/types.ts index e976f060..fb3dbf9c 100644 --- a/packages/instant-meilisearch/src/types/types.ts +++ b/packages/instant-meilisearch/src/types/types.ts @@ -15,7 +15,12 @@ export type { } export type { SearchResponse as AlgoliaSearchResponse } from '@algolia/client-search' -export type { Filter, FacetDistribution, MeiliSearch } from 'meilisearch' +export type { + Filter, + FacetDistribution, + MeiliSearch, + FacetStats as MeiliFacetStats, +} from 'meilisearch' export type InstantSearchParams = AlgoliaMultipleQueriesQuery['params'] @@ -109,3 +114,25 @@ export type MultiSearchResolver = { instantSearchPagination: PaginationState[] ) => Promise } + +export type AlgoliaFacetStats = Record< + string, + { + /** + * The minimum value in the result set. + */ + min: number + /** + * The maximum value in the result set. + */ + max: number + /** + * The average facet value in the result set. + */ + avg: number + /** + * The sum of all values in the result set. + */ + sum: number + } +>