From 9e9d8394067bec35425b7d66f94fcce504faee7f Mon Sep 17 00:00:00 2001 From: Haroen Viaene Date: Mon, 5 Jul 2021 14:40:28 +0200 Subject: [PATCH] feat(facets): apply result from facet ordering (#4784) * feat(facets): apply sort from facetOrdering * feat(facets): apply result from facet ordering This adds a new option "facetOrdering" (boolean) to refinementList, menu, hierarchicalMenu which will read facet ordering from the results if available, but fall back to sortBy if no facetOrdering is available. The option facetOrdering defaults to `true` if no sortBy is given, to make it apply out of the box. references: - NLP-110 - [RFC 45](https://github.com/algolia/instantsearch-rfcs/blob/master/accepted/flexible-facet-values.md) * forward facetOrdering option from widget * suppress v3 ts errors * remove option * test: rename --- .../__tests__/connectHierarchicalMenu-test.ts | 160 ++++++++++++++++ .../connectHierarchicalMenu.ts | 12 +- .../menu/__tests__/connectMenu-test.ts | 175 +++++++++++++++++- src/connectors/menu/connectMenu.ts | 7 +- .../__tests__/connectRefinementList-test.ts | 113 +++++++++++ .../refinement-list/connectRefinementList.ts | 9 +- .../__tests__/hierarchical-menu-test.ts | 6 +- tsconfig.v3.json | 5 +- 8 files changed, 475 insertions(+), 12 deletions(-) diff --git a/src/connectors/hierarchical-menu/__tests__/connectHierarchicalMenu-test.ts b/src/connectors/hierarchical-menu/__tests__/connectHierarchicalMenu-test.ts index c24981ace5..59cc466947 100644 --- a/src/connectors/hierarchical-menu/__tests__/connectHierarchicalMenu-test.ts +++ b/src/connectors/hierarchical-menu/__tests__/connectHierarchicalMenu-test.ts @@ -691,6 +691,166 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/hierarchica canToggleShowMore: false, }); }); + + describe('facetOrdering', () => { + const resultsViaFacetOrdering = [ + { + count: 47, + data: null, + exhaustive: true, + isRefined: false, + label: 'Outdoor', + value: 'Outdoor', + }, + { + count: 880, + data: [ + { + count: 173, + data: null, + exhaustive: true, + isRefined: false, + label: 'Frames & pictures', + value: 'Decoration > Frames & pictures', + }, + { + count: 193, + data: null, + exhaustive: true, + isRefined: false, + label: 'Candle holders & candles', + value: 'Decoration > Candle holders & candles', + }, + ], + exhaustive: true, + isRefined: true, + label: 'Decoration', + value: 'Decoration', + }, + ]; + const resultsViaSortBy = [ + { + count: 880, + data: [ + { + count: 193, + data: null, + exhaustive: true, + isRefined: false, + label: 'Candle holders & candles', + value: 'Decoration > Candle holders & candles', + }, + { + count: 173, + data: null, + exhaustive: true, + isRefined: false, + label: 'Frames & pictures', + value: 'Decoration > Frames & pictures', + }, + ], + exhaustive: true, + isRefined: true, + label: 'Decoration', + value: 'Decoration', + }, + { + count: 47, + data: null, + exhaustive: true, + isRefined: false, + label: 'Outdoor', + value: 'Outdoor', + }, + ]; + + test.each` + facetOrderingInResult | sortBy | expected + ${true} | ${undefined} | ${resultsViaFacetOrdering} + ${false} | ${undefined} | ${resultsViaSortBy} + ${true} | ${['name:asc']} | ${resultsViaSortBy} + ${false} | ${['name:asc']} | ${resultsViaSortBy} + `( + 'renderingContent present: $facetOrderingInResult, sortBy: $sortBy', + ({ facetOrderingInResult, sortBy, expected }) => { + const renderFn = jest.fn(); + const unmountFn = jest.fn(); + const createHierarchicalMenu = connectHierarchicalMenu( + renderFn, + unmountFn + ); + const hierarchicalMenu = createHierarchicalMenu({ + attributes: ['category', 'subCategory'], + sortBy, + }); + const helper = algoliasearchHelper( + createSearchClient(), + 'indexName', + hierarchicalMenu.getWidgetSearchParameters!( + new SearchParameters(), + { + uiState: { + hierarchicalMenu: { + category: ['Decoration'], + }, + }, + } + ) + ); + + hierarchicalMenu.init!(createInitOptions({ helper })); + + const renderingContent = facetOrderingInResult + ? { + facetOrdering: { + values: { + category: { + order: ['Outdoor'], + sortRemainingBy: 'alpha' as const, + }, + subCategory: { + order: ['Decoration > Frames & pictures'], + sortRemainingBy: 'count' as const, + }, + }, + }, + } + : undefined; + + const results = new SearchResults(helper.state, [ + createSingleSearchResponse({ + renderingContent, + facets: { + category: { + Decoration: 880, + }, + subCategory: { + 'Decoration > Candle holders & candles': 193, + 'Decoration > Frames & pictures': 173, + }, + }, + }), + createSingleSearchResponse({ + facets: { + category: { + Decoration: 880, + Outdoor: 47, + }, + }, + }), + ]); + + const renderState = hierarchicalMenu.getWidgetRenderState( + createRenderOptions({ + helper, + results, + }) + ); + + expect(renderState.items).toEqual(expected); + } + ); + }); }); describe('getWidgetUiState', () => { diff --git a/src/connectors/hierarchical-menu/connectHierarchicalMenu.ts b/src/connectors/hierarchical-menu/connectHierarchicalMenu.ts index d71d7ebf1c..eb99992882 100644 --- a/src/connectors/hierarchical-menu/connectHierarchicalMenu.ts +++ b/src/connectors/hierarchical-menu/connectHierarchicalMenu.ts @@ -23,6 +23,8 @@ const withUsage = createDocumentationMessageGenerator({ connector: true, }); +const DEFAULT_SORT = ['name:asc']; + export type HierarchicalMenuItem = { /** * Value of the menu item. @@ -79,6 +81,8 @@ export type HierarchicalMenuConnectorParams = { /** * How to sort refinements. Possible values: `count|isRefined|name:asc|name:desc`. * You can also use a sort function that behaves like the standard Javascript [compareFunction](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#Syntax). + * + * If a facetOrdering is set in the index settings, it is used when sortBy isn't passed */ sortBy?: SortBy; /** @@ -174,7 +178,7 @@ const connectHierarchicalMenu: HierarchicalMenuConnector = function connectHiera limit = 10, showMore = false, showMoreLimit = 20, - sortBy = ['name:asc'], + sortBy = DEFAULT_SORT, transformItems = (items => items) as TransformItems, } = widgetParams || {}; @@ -273,11 +277,6 @@ const connectHierarchicalMenu: HierarchicalMenuConnector = function connectHiera ); }, - /** - * @param {Object} param0 cleanup arguments - * @param {any} param0.state current search parameters - * @returns {any} next search parameters - */ dispose({ state }) { unmountFn(); @@ -336,6 +335,7 @@ const connectHierarchicalMenu: HierarchicalMenuConnector = function connectHiera if (results) { const facetValues = results.getFacetValues(hierarchicalFacetName, { sortBy, + facetOrdering: sortBy === DEFAULT_SORT, }); const facetItems = facetValues && !Array.isArray(facetValues) && facetValues.data diff --git a/src/connectors/menu/__tests__/connectMenu-test.ts b/src/connectors/menu/__tests__/connectMenu-test.ts index 45bc496ca0..e4a2535c03 100644 --- a/src/connectors/menu/__tests__/connectMenu-test.ts +++ b/src/connectors/menu/__tests__/connectMenu-test.ts @@ -560,7 +560,7 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/menu/js/#co }); describe('getWidgetRenderState', () => { - test('returns the widget render state', () => { + test('returns the widget render state (init)', () => { const renderFn = jest.fn(); const unmountFn = jest.fn(); const createMenu = connectMenu(renderFn, unmountFn); @@ -589,6 +589,179 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/menu/js/#co widgetParams: { attribute: 'brand' }, }); }); + + test('returns the widget render state (render)', () => { + const renderFn = jest.fn(); + const unmountFn = jest.fn(); + const createMenu = connectMenu(renderFn, unmountFn); + const menu = createMenu({ + attribute: 'brand', + }); + const helper = jsHelper( + createSearchClient(), + 'indexName', + menu.getWidgetSearchParameters!(new SearchParameters(), { uiState: {} }) + ); + + const renderState1 = menu.getWidgetRenderState( + createRenderOptions({ + helper, + results: new SearchResults(helper.state, [ + createSingleSearchResponse({ + facets: { + brand: { + Apple: 100, + Samsung: 1, + }, + }, + }), + ]), + }) + ); + + expect(renderState1).toEqual({ + items: [ + { + count: 100, + data: null, + exhaustive: true, + isRefined: false, + label: 'Apple', + value: 'Apple', + }, + { + count: 1, + data: null, + exhaustive: true, + isRefined: false, + label: 'Samsung', + value: 'Samsung', + }, + ], + createURL: expect.any(Function), + refine: expect.any(Function), + sendEvent: expect.any(Function), + canRefine: true, + isShowingMore: false, + toggleShowMore: expect.any(Function), + canToggleShowMore: false, + widgetParams: { attribute: 'brand' }, + }); + }); + + describe('facetOrdering', () => { + const resultsViaFacetOrdering = [ + { + count: 1, + data: null, + exhaustive: true, + isRefined: false, + label: 'Samsung', + value: 'Samsung', + }, + { + count: 100, + data: null, + exhaustive: true, + isRefined: false, + label: 'Apple', + value: 'Apple', + }, + { + count: 3, + data: null, + exhaustive: true, + isRefined: false, + label: 'Algolia', + value: 'Algolia', + }, + ]; + const resultsViaSortBy = [ + { + count: 3, + data: null, + exhaustive: true, + isRefined: false, + label: 'Algolia', + value: 'Algolia', + }, + { + count: 100, + data: null, + exhaustive: true, + isRefined: false, + label: 'Apple', + value: 'Apple', + }, + { + count: 1, + data: null, + exhaustive: true, + isRefined: false, + label: 'Samsung', + value: 'Samsung', + }, + ]; + + test.each` + facetOrderingInResult | sortBy | expected + ${true} | ${undefined} | ${resultsViaFacetOrdering} + ${false} | ${undefined} | ${resultsViaSortBy} + ${true} | ${['name:asc']} | ${resultsViaSortBy} + ${false} | ${['name:asc']} | ${resultsViaSortBy} + `( + 'renderingContent present: $facetOrderingInResult, sortBy: $sortBy', + ({ facetOrderingInResult, sortBy, expected }) => { + const renderFn = jest.fn(); + const unmountFn = jest.fn(); + const createMenu = connectMenu(renderFn, unmountFn); + const menu = createMenu({ + attribute: 'brand', + sortBy, + }); + const helper = jsHelper( + createSearchClient(), + 'indexName', + menu.getWidgetSearchParameters!(new SearchParameters(), { + uiState: {}, + }) + ); + + const renderingContent = facetOrderingInResult + ? { + facetOrdering: { + values: { + brand: { + order: ['Samsung'], + sortRemainingBy: 'count' as const, + }, + }, + }, + } + : undefined; + + const renderState1 = menu.getWidgetRenderState( + createRenderOptions({ + helper, + results: new SearchResults(helper.state, [ + createSingleSearchResponse({ + renderingContent, + facets: { + brand: { + Apple: 100, + Algolia: 3, + Samsung: 1, + }, + }, + }), + ]), + }) + ); + + expect(renderState1.items).toEqual(expected); + } + ); + }); }); describe('showMore', () => { diff --git a/src/connectors/menu/connectMenu.ts b/src/connectors/menu/connectMenu.ts index 1fe4bf610e..4ad7148a02 100644 --- a/src/connectors/menu/connectMenu.ts +++ b/src/connectors/menu/connectMenu.ts @@ -19,6 +19,8 @@ const withUsage = createDocumentationMessageGenerator({ connector: true, }); +const DEFAULT_SORT = ['isRefined', 'name:asc']; + export type MenuItem = { /** * The value of the menu item. @@ -59,6 +61,8 @@ export type MenuConnectorParams = { * How to sort refinements. Possible values: `count|isRefined|name:asc|name:desc`. * * You can also use a sort function that behaves like the standard Javascript [compareFunction](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#Syntax). + * + * If a facetOrdering is set in the index settings, it is used when sortBy isn't passed */ sortBy?: SortBy; /** @@ -147,7 +151,7 @@ const connectMenu: MenuConnector = function connectMenu( limit = 10, showMore = false, showMoreLimit = 20, - sortBy = ['isRefined', 'name:asc'], + sortBy = DEFAULT_SORT, transformItems = (items => items) as TransformItems, } = widgetParams || {}; @@ -286,6 +290,7 @@ const connectMenu: MenuConnector = function connectMenu( if (results) { const facetValues = results.getFacetValues(attribute, { sortBy, + facetOrdering: sortBy === DEFAULT_SORT, }); const facetItems = facetValues && !Array.isArray(facetValues) && facetValues.data diff --git a/src/connectors/refinement-list/__tests__/connectRefinementList-test.ts b/src/connectors/refinement-list/__tests__/connectRefinementList-test.ts index 6c03aec07c..e14c8ab88b 100644 --- a/src/connectors/refinement-list/__tests__/connectRefinementList-test.ts +++ b/src/connectors/refinement-list/__tests__/connectRefinementList-test.ts @@ -2589,6 +2589,119 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/refinement- }) ); }); + + describe('facetOrdering', () => { + const resultsViaFacetOrdering = [ + { + count: 66, + highlighted: 'Microsoft', + isRefined: false, + label: 'Microsoft', + value: 'Microsoft', + }, + { + count: 88, + highlighted: 'Apple', + isRefined: true, + label: 'Apple', + value: 'Apple', + }, + { + count: 44, + highlighted: 'Samsung', + isRefined: true, + label: 'Samsung', + value: 'Samsung', + }, + ]; + const resultsViaSortBy = [ + { + count: 88, + highlighted: 'Apple', + isRefined: true, + label: 'Apple', + value: 'Apple', + }, + { + count: 44, + highlighted: 'Samsung', + isRefined: true, + label: 'Samsung', + value: 'Samsung', + }, + { + count: 66, + highlighted: 'Microsoft', + isRefined: false, + label: 'Microsoft', + value: 'Microsoft', + }, + ]; + + test.each` + facetOrderingInResult | sortBy | expected + ${true} | ${undefined} | ${resultsViaFacetOrdering} + ${false} | ${undefined} | ${resultsViaSortBy} + ${true} | ${['isRefined']} | ${resultsViaSortBy} + ${false} | ${['isRefined']} | ${resultsViaSortBy} + `( + 'renderingContent present: $facetOrderingInResult, sortBy: $sortBy', + ({ facetOrderingInResult, sortBy, expected }) => { + const renderFn = jest.fn(); + const unmountFn = jest.fn(); + const createRefinementList = connectRefinementList( + renderFn, + unmountFn + ); + const refinementList = createRefinementList({ + attribute: 'brand', + sortBy, + }); + const helper = jsHelper( + createSearchClient(), + 'indexName', + refinementList.getWidgetSearchParameters!(new SearchParameters(), { + uiState: { + refinementList: { brand: ['Apple', 'Samsung'] }, + }, + }) + ); + + const renderingContent = facetOrderingInResult + ? { + facetOrdering: { + values: { + brand: { + order: ['Microsoft'], + sortRemainingBy: 'alpha' as const, + }, + }, + }, + } + : undefined; + + const renderState1 = refinementList.getWidgetRenderState( + createRenderOptions({ + helper, + results: new SearchResults(helper.state, [ + createSingleSearchResponse({ + renderingContent, + facets: { + brand: { + Apple: 88, + Microsoft: 66, + Samsung: 44, + }, + }, + }), + ]), + }) + ); + + expect(renderState1.items).toEqual(expected); + } + ); + }); }); describe('getWidgetSearchParameters', () => { diff --git a/src/connectors/refinement-list/connectRefinementList.ts b/src/connectors/refinement-list/connectRefinementList.ts index 22c5e44c31..5951d9b956 100644 --- a/src/connectors/refinement-list/connectRefinementList.ts +++ b/src/connectors/refinement-list/connectRefinementList.ts @@ -26,6 +26,8 @@ const withUsage = createDocumentationMessageGenerator({ connector: true, }); +const DEFAULT_SORT = ['isRefined', 'count:desc', 'name:asc']; + export type RefinementListItem = { /** * The value of the refinement list item. @@ -74,6 +76,10 @@ export type RefinementListConnectorParams = { showMoreLimit?: number; /** * How to sort refinements. Possible values: `count|isRefined|name:asc|name:desc`. + * + * You can also use a sort function that behaves like the standard Javascript [compareFunction](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#Syntax). + * + * If a facetOrdering is set in the index settings, it is used when sortBy isn't passed */ sortBy?: SortBy; /** @@ -182,7 +188,7 @@ const connectRefinementList: RefinementListConnector = function connectRefinemen limit = 10, showMore = false, showMoreLimit = 20, - sortBy = ['isRefined', 'count:desc', 'name:asc'], + sortBy = DEFAULT_SORT, escapeFacetValues = true, transformItems = (items => items) as TransformItems, } = widgetParams || {}; @@ -386,6 +392,7 @@ const connectRefinementList: RefinementListConnector = function connectRefinemen if (results) { const values = results.getFacetValues(attribute, { sortBy, + facetOrdering: sortBy === DEFAULT_SORT, }); facetValues = values && Array.isArray(values) ? values : []; items = transformItems( diff --git a/src/widgets/hierarchical-menu/__tests__/hierarchical-menu-test.ts b/src/widgets/hierarchical-menu/__tests__/hierarchical-menu-test.ts index ea91fbe8c4..0da2df13e4 100644 --- a/src/widgets/hierarchical-menu/__tests__/hierarchical-menu-test.ts +++ b/src/widgets/hierarchical-menu/__tests__/hierarchical-menu-test.ts @@ -133,19 +133,21 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/hierarchica expect(results.getFacetValues).toHaveBeenCalledTimes(1); expect(results.getFacetValues).toHaveBeenCalledWith('hello', { + facetOrdering: true, sortBy: ['name:asc'], }); }); it('has a sortBy option', () => { - widget = hierarchicalMenu({ ...options, sortBy: ['name:asc'] }); + widget = hierarchicalMenu({ ...options, sortBy: ['name:desc'] }); widget.init!(createInitOptions({ helper })); widget.render!(createRenderOptions({ results, state })); expect(results.getFacetValues).toHaveBeenCalledTimes(1); expect(results.getFacetValues).toHaveBeenCalledWith('hello', { - sortBy: ['name:asc'], + facetOrdering: false, + sortBy: ['name:desc'], }); }); diff --git a/tsconfig.v3.json b/tsconfig.v3.json index f7f443fd37..356a202897 100644 --- a/tsconfig.v3.json +++ b/tsconfig.v3.json @@ -13,6 +13,9 @@ // v3 has a wrong definition for optionalWords (only accepts string[]) "src/connectors/voice-search/__tests__/connectVoiceSearch-test.ts", // v3 does not have renderingContent (only errors in the test) - "src/connectors/dynamic-widgets/__tests__/connectDynamicWidgets-test.ts" + "src/connectors/dynamic-widgets/__tests__/connectDynamicWidgets-test.ts", + "src/connectors/hierarchical-menu/__tests__/connectHierarchicalMenu-test.ts", + "src/connectors/menu/__tests__/connectMenu-test.ts", + "src/connectors/refinement-list/__tests__/connectRefinementList-test.ts" ] }