diff --git a/dev/app/builtin/stories/hierarchical-menu.stories.js b/dev/app/builtin/stories/hierarchical-menu.stories.js index c9de784c8e..18ccce3f8f 100644 --- a/dev/app/builtin/stories/hierarchical-menu.stories.js +++ b/dev/app/builtin/stories/hierarchical-menu.stories.js @@ -85,6 +85,44 @@ export default () => { ); }) ) + .add( + 'with show more', + wrapWithHits(container => { + window.search.addWidget( + instantsearch.widgets.hierarchicalMenu({ + container, + attributes: [ + 'hierarchicalCategories.lvl0', + 'hierarchicalCategories.lvl1', + 'hierarchicalCategories.lvl2', + 'hierarchicalCategories.lvl3', + ], + showMore: true, + limit: 3, + showMoreLimit: 10, + }) + ); + }) + ) + .add( + 'with show more (exhaustive display)', + wrapWithHits(container => { + window.search.addWidget( + instantsearch.widgets.hierarchicalMenu({ + container, + attributes: [ + 'hierarchicalCategories.lvl0', + 'hierarchicalCategories.lvl1', + 'hierarchicalCategories.lvl2', + 'hierarchicalCategories.lvl3', + ], + showMore: true, + limit: 200, + showMoreLimit: 1000, + }) + ); + }) + ) .add( 'with transformed items', wrapWithHits(container => { diff --git a/src/components/RefinementList/RefinementList.js b/src/components/RefinementList/RefinementList.js index 410dbac8a3..4e239964a4 100644 --- a/src/components/RefinementList/RefinementList.js +++ b/src/components/RefinementList/RefinementList.js @@ -41,6 +41,7 @@ export class RawRefinementList extends Component { {...this.props} depth={this.props.depth + 1} facetValues={facetValue.data} + showMore={false} /> ); } diff --git a/src/connectors/hierarchical-menu/__tests__/connectHierarchicalMenu-test.js b/src/connectors/hierarchical-menu/__tests__/connectHierarchicalMenu-test.js index 2d7c40802b..f16f30bcfb 100644 --- a/src/connectors/hierarchical-menu/__tests__/connectHierarchicalMenu-test.js +++ b/src/connectors/hierarchical-menu/__tests__/connectHierarchicalMenu-test.js @@ -5,59 +5,156 @@ const SearchParameters = jsHelper.SearchParameters; import connectHierarchicalMenu from '../connectHierarchicalMenu.js'; describe('connectHierarchicalMenu', () => { - it('It should compute getConfiguration() correctly', () => { - const rendering = jest.fn(); - const makeWidget = connectHierarchicalMenu(rendering); + describe('getConfiguration', () => { + it('It should compute getConfiguration() correctly', () => { + const rendering = jest.fn(); + const makeWidget = connectHierarchicalMenu(rendering); - const widget = makeWidget({ attributes: ['category', 'sub_category'] }); + const widget = makeWidget({ attributes: ['category', 'sub_category'] }); + + // when there is no hierarchicalFacets into current configuration + { + const config = widget.getConfiguration({}); + expect(config).toEqual({ + hierarchicalFacets: [ + { + attributes: ['category', 'sub_category'], + name: 'category', + rootPath: null, + separator: ' > ', + showParentLevel: true, + }, + ], + maxValuesPerFacet: 10, + }); + } - // when there is no hierarchicalFacets into current configuration - { - const config = widget.getConfiguration({}); - expect(config).toEqual({ - hierarchicalFacets: [ - { - attributes: ['category', 'sub_category'], - name: 'category', - rootPath: null, - separator: ' > ', - showParentLevel: true, - }, - ], - maxValuesPerFacet: 10, - }); - } + // when there is an identical hierarchicalFacets into current configuration + { + const spy = jest.spyOn(global.console, 'warn'); + const config = widget.getConfiguration({ + hierarchicalFacets: [{ name: 'category' }], + }); + expect(config).toEqual({}); + expect(spy).toHaveBeenCalled(); + spy.mockReset(); + spy.mockRestore(); + } + + // when there is already a different hierarchicalFacets into current configuration + { + const config = widget.getConfiguration({ + hierarchicalFacets: [{ name: 'foo' }], + }); + expect(config).toEqual({ + hierarchicalFacets: [ + { + attributes: ['category', 'sub_category'], + name: 'category', + rootPath: null, + separator: ' > ', + showParentLevel: true, + }, + ], + maxValuesPerFacet: 10, + }); + } + }); - // when there is an identical hierarchicalFacets into current configuration - { - const spy = jest.spyOn(global.console, 'warn'); - const config = widget.getConfiguration({ - hierarchicalFacets: [{ name: 'category' }], - }); - expect(config).toEqual({}); - expect(spy).toHaveBeenCalled(); - spy.mockReset(); - spy.mockRestore(); - } - - // when there is already a different hierarchicalFacets into current configuration - { - const config = widget.getConfiguration({ - hierarchicalFacets: [{ name: 'foo' }], + it('sets the correct limit with showMore', () => { + const rendering = jest.fn(); + const makeWidget = connectHierarchicalMenu(rendering); + + const widget = makeWidget({ + attributes: ['category', 'sub_category'], + showMore: true, + limit: 3, + showMoreLimit: 100, }); - expect(config).toEqual({ - hierarchicalFacets: [ - { - attributes: ['category', 'sub_category'], - name: 'category', - rootPath: null, - separator: ' > ', - showParentLevel: true, - }, - ], - maxValuesPerFacet: 10, + + // when there is no other limit set + { + const config = widget.getConfiguration({}); + expect(config).toEqual({ + hierarchicalFacets: [ + { + attributes: ['category', 'sub_category'], + name: 'category', + rootPath: null, + separator: ' > ', + showParentLevel: true, + }, + ], + maxValuesPerFacet: 100, + }); + } + + // when there is a bigger already limit set + { + const config = widget.getConfiguration({ + maxValuesPerFacet: 101, + }); + expect(config).toEqual({ + hierarchicalFacets: [ + { + attributes: ['category', 'sub_category'], + name: 'category', + rootPath: null, + separator: ' > ', + showParentLevel: true, + }, + ], + maxValuesPerFacet: 101, + }); + } + }); + + it('sets the correct custom limit', () => { + const rendering = jest.fn(); + const makeWidget = connectHierarchicalMenu(rendering); + + const widget = makeWidget({ + attributes: ['category', 'sub_category'], + showMore: true, + limit: 3, }); - } + + // when there is no other limit set + { + const config = widget.getConfiguration({}); + expect(config).toEqual({ + hierarchicalFacets: [ + { + attributes: ['category', 'sub_category'], + name: 'category', + rootPath: null, + separator: ' > ', + showParentLevel: true, + }, + ], + maxValuesPerFacet: 3, + }); + } + + // when there is a bigger already limit set + { + const config = widget.getConfiguration({ + maxValuesPerFacet: 101, + }); + expect(config).toEqual({ + hierarchicalFacets: [ + { + attributes: ['category', 'sub_category'], + name: 'category', + rootPath: null, + separator: ' > ', + showParentLevel: true, + }, + ], + maxValuesPerFacet: 101, + }); + } + }); }); it('Renders during init and render', () => { @@ -438,4 +535,117 @@ describe('connectHierarchicalMenu', () => { }); }); }); + + describe('show more', () => { + it('can toggle the limits', () => { + const rendering = jest.fn(); + const makeWidget = connectHierarchicalMenu(rendering); + const widget = makeWidget({ + attributes: ['category'], + limit: 2, + showMoreLimit: 5, + }); + + const helper = jsHelper({}, '', widget.getConfiguration({})); + helper.search = jest.fn(); + + widget.init({ + helper, + state: helper.state, + createURL: () => '#', + onHistoryChange: () => {}, + }); + + widget.render({ + results: new SearchResults(helper.state, [ + { + hits: [], + facets: { + category: { + a: 880, + b: 880, + c: 880, + d: 880, + }, + }, + }, + { + facets: { + category: { + a: 880, + b: 880, + c: 880, + d: 880, + }, + }, + }, + ]), + state: helper.state, + helper, + createURL: () => '#', + }); + + const { toggleShowMore } = rendering.mock.calls[1][0]; + + expect(rendering).toHaveBeenLastCalledWith( + expect.objectContaining({ + items: [ + { + label: 'a', + value: 'a', + count: 880, + isRefined: false, + data: null, + }, + { + label: 'b', + value: 'b', + count: 880, + isRefined: false, + data: null, + }, + ], + }), + expect.anything() + ); + + toggleShowMore(); + + expect(rendering).toHaveBeenLastCalledWith( + expect.objectContaining({ + items: [ + { + label: 'a', + value: 'a', + count: 880, + isRefined: false, + data: null, + }, + { + label: 'b', + value: 'b', + count: 880, + isRefined: false, + data: null, + }, + { + label: 'c', + value: 'c', + count: 880, + isRefined: false, + data: null, + }, + { + label: 'd', + value: 'd', + count: 880, + isRefined: false, + data: null, + }, + ], + }), + expect.anything() + ); + }); + }); }); diff --git a/src/connectors/hierarchical-menu/connectHierarchicalMenu.js b/src/connectors/hierarchical-menu/connectHierarchicalMenu.js index e2562b0338..b5e3582c14 100644 --- a/src/connectors/hierarchical-menu/connectHierarchicalMenu.js +++ b/src/connectors/hierarchical-menu/connectHierarchicalMenu.js @@ -83,6 +83,7 @@ export default function connectHierarchicalMenu(renderFn, unmountFn) { rootPath = null, showParentLevel = true, limit = 10, + showMoreLimit, sortBy = ['name:asc'], transformItems = items => items, } = widgetParams; @@ -97,6 +98,26 @@ export default function connectHierarchicalMenu(renderFn, unmountFn) { const [hierarchicalFacetName] = attributes; return { + isShowingMore: false, + + // Provide the same function to the `renderFn` so that way the user + // has to only bind it once when `isFirstRendering` for instance + toggleShowMore() {}, + cachedToggleShowMore() { + this.toggleShowMore(); + }, + + createToggleShowMore(renderOptions) { + return () => { + this.isShowingMore = !this.isShowingMore; + this.render(renderOptions); + }; + }, + + getLimit() { + return this.isShowingMore ? showMoreLimit : limit; + }, + getConfiguration: currentConfiguration => { if (currentConfiguration.hierarchicalFacets) { const isFacetSet = find( @@ -118,7 +139,7 @@ export default function connectHierarchicalMenu(renderFn, unmountFn) { } } - return { + const widgetConfiguration = { hierarchicalFacets: [ { name: hierarchicalFacetName, @@ -128,14 +149,30 @@ export default function connectHierarchicalMenu(renderFn, unmountFn) { showParentLevel, }, ], - maxValuesPerFacet: - currentConfiguration.maxValuesPerFacet !== undefined - ? Math.max(currentConfiguration.maxValuesPerFacet, limit) - : limit, }; + + if (limit !== undefined) { + const currentMaxValuesPerFacet = + currentConfiguration.maxValuesPerFacet || 0; + if (showMoreLimit === undefined) { + widgetConfiguration.maxValuesPerFacet = Math.max( + currentMaxValuesPerFacet, + limit + ); + } else { + widgetConfiguration.maxValuesPerFacet = Math.max( + currentMaxValuesPerFacet, + limit, + showMoreLimit + ); + } + } + + return widgetConfiguration; }, init({ helper, createURL, instantSearchInstance }) { + this.cachedToggleShowMore = this.cachedToggleShowMore.bind(this); this._refine = function(facetValue) { helper.toggleRefinement(hierarchicalFacetName, facetValue).search(); }; @@ -154,6 +191,9 @@ export default function connectHierarchicalMenu(renderFn, unmountFn) { refine: this._refine, instantSearchInstance, widgetParams, + isShowingMore: false, + toggleShowMore: this.cachedToggleShowMore, + canToggleShowMore: false, }, true ); @@ -161,7 +201,7 @@ export default function connectHierarchicalMenu(renderFn, unmountFn) { _prepareFacetValues(facetValues, state) { return facetValues - .slice(0, limit) + .slice(0, this.getLimit()) .map(({ name: label, path: value, ...subValue }) => { if (Array.isArray(subValue.data)) { subValue.data = this._prepareFacetValues(subValue.data, state); @@ -170,13 +210,19 @@ export default function connectHierarchicalMenu(renderFn, unmountFn) { }); }, - render({ results, state, createURL, instantSearchInstance }) { + render(renderOptions) { + const { + results, + state, + createURL, + instantSearchInstance, + } = renderOptions; + + const facetValues = + results.getFacetValues(hierarchicalFacetName, { sortBy }).data || []; const items = transformItems( - this._prepareFacetValues( - results.getFacetValues(hierarchicalFacetName, { sortBy }).data || - [], - state - ) + this._prepareFacetValues(facetValues), + state ); // Bind createURL to this specific attribute @@ -186,6 +232,23 @@ export default function connectHierarchicalMenu(renderFn, unmountFn) { ); } + const maxValuesPerFacetConfig = state.getQueryParameter( + 'maxValuesPerFacet' + ); + const currentLimit = this.getLimit(); + // If the limit is the max number of facet retrieved it is impossible to know + // if the facets are exhaustive. The only moment we are sure it is exhaustive + // is when it is strictly under the number requested unless we know that another + // widget has requested more values (maxValuesPerFacet > getLimit()). + // Because this is used for making the search of facets unable or not, it is important + // to be conservative here. + const hasExhaustiveItems = + maxValuesPerFacetConfig > currentLimit + ? facetValues.length <= currentLimit + : facetValues.length < currentLimit; + + this.toggleShowMore = this.createToggleShowMore(renderOptions); + renderFn( { createURL: _createURL, @@ -193,6 +256,11 @@ export default function connectHierarchicalMenu(renderFn, unmountFn) { refine: this._refine, instantSearchInstance, widgetParams, + isShowingMore: this.isShowingMore, + toggleShowMore: this.cachedToggleShowMore, + canToggleShowMore: showMoreLimit + ? this.isShowingMore || !hasExhaustiveItems + : false, }, false ); diff --git a/src/widgets/hierarchical-menu/__tests__/__snapshots__/hierarchical-menu-test.js.snap b/src/widgets/hierarchical-menu/__tests__/__snapshots__/hierarchical-menu-test.js.snap index 84995fe448..b150842410 100644 --- a/src/widgets/hierarchical-menu/__tests__/__snapshots__/hierarchical-menu-test.js.snap +++ b/src/widgets/hierarchical-menu/__tests__/__snapshots__/hierarchical-menu-test.js.snap @@ -2,6 +2,7 @@ exports[`hierarchicalMenu() render calls ReactDOM.render 1`] = ` {{label}} {{#helpers.formatNumber}}{{count}}{{/helpers.formatNumber}}", + "show-more-active": "", + "show-more-inactive": "", }, "templatesConfig": undefined, "transformData": undefined, @@ -44,15 +49,19 @@ exports[`hierarchicalMenu() render calls ReactDOM.render 1`] = ` "footer": false, "header": false, "item": false, + "show-more-active": false, + "show-more-inactive": false, }, } } toggleRefinement={[Function]} + toggleShowMore={[Function]} /> `; exports[`hierarchicalMenu() render has a templates option 1`] = ` Show less", + "show-more-inactive": "", }, "templatesConfig": undefined, "transformData": undefined, @@ -95,15 +108,19 @@ exports[`hierarchicalMenu() render has a templates option 1`] = ` "footer": true, "header": true, "item": true, + "show-more-active": false, + "show-more-inactive": false, }, } } toggleRefinement={[Function]} + toggleShowMore={[Function]} /> `; exports[`hierarchicalMenu() render has a transformItems options 1`] = ` {{label}} {{#helpers.formatNumber}}{{count}}{{/helpers.formatNumber}}", + "show-more-active": "", + "show-more-inactive": "", }, "templatesConfig": undefined, "transformData": undefined, @@ -148,15 +169,19 @@ exports[`hierarchicalMenu() render has a transformItems options 1`] = ` "footer": false, "header": false, "item": false, + "show-more-active": false, + "show-more-inactive": false, }, } } toggleRefinement={[Function]} + toggleShowMore={[Function]} /> `; exports[`hierarchicalMenu() render sets facetValues to empty array when no results 1`] = ` {{label}} {{#helpers.formatNumber}}{{count}}{{/helpers.formatNumber}}", + "show-more-active": "", + "show-more-inactive": "", }, "templatesConfig": undefined, "transformData": undefined, @@ -188,15 +217,19 @@ exports[`hierarchicalMenu() render sets facetValues to empty array when no resul "footer": false, "header": false, "item": false, + "show-more-active": false, + "show-more-inactive": false, }, } } toggleRefinement={[Function]} + toggleShowMore={[Function]} /> `; exports[`hierarchicalMenu() render sets shouldAutoHideContainer to true when no results 1`] = ` {{label}} {{#helpers.formatNumber}}{{count}}{{/helpers.formatNumber}}", + "show-more-active": "", + "show-more-inactive": "", }, "templatesConfig": undefined, "transformData": undefined, @@ -228,15 +265,19 @@ exports[`hierarchicalMenu() render sets shouldAutoHideContainer to true when no "footer": false, "header": false, "item": false, + "show-more-active": false, + "show-more-inactive": false, }, } } toggleRefinement={[Function]} + toggleShowMore={[Function]} /> `; exports[`hierarchicalMenu() render understand provided cssClasses 1`] = ` {{label}} {{#helpers.formatNumber}}{{count}}{{/helpers.formatNumber}}", + "show-more-active": "", + "show-more-inactive": "", }, "templatesConfig": undefined, "transformData": undefined, @@ -279,9 +324,12 @@ exports[`hierarchicalMenu() render understand provided cssClasses 1`] = ` "footer": false, "header": false, "item": false, + "show-more-active": false, + "show-more-inactive": false, }, } } toggleRefinement={[Function]} + toggleShowMore={[Function]} /> `; diff --git a/src/widgets/hierarchical-menu/__tests__/hierarchical-menu-test.js b/src/widgets/hierarchical-menu/__tests__/hierarchical-menu-test.js index 709a1036e9..8514004b1b 100644 --- a/src/widgets/hierarchical-menu/__tests__/hierarchical-menu-test.js +++ b/src/widgets/hierarchical-menu/__tests__/hierarchical-menu-test.js @@ -1,4 +1,6 @@ import hierarchicalMenu from '../hierarchical-menu'; +import jsHelper from 'algoliasearch-helper'; +const SearchParameters = jsHelper.SearchParameters; describe('hierarchicalMenu()', () => { let container; @@ -143,9 +145,8 @@ describe('hierarchicalMenu()', () => { toggleRefinement: jest.fn().mockReturnThis(), search: jest.fn(), }; - state = { - toggleRefinement: jest.fn(), - }; + state = new SearchParameters(); + state.toggleRefinement = jest.fn(); options = { container, attributes }; createURL = () => '#'; }); diff --git a/src/widgets/hierarchical-menu/defaultTemplates.js b/src/widgets/hierarchical-menu/defaultTemplates.js index 64def3bcd6..a8608f6412 100644 --- a/src/widgets/hierarchical-menu/defaultTemplates.js +++ b/src/widgets/hierarchical-menu/defaultTemplates.js @@ -4,4 +4,8 @@ export default { item: '{{label}} {{#helpers.formatNumber}}{{count}}{{/helpers.formatNumber}}', footer: '', + 'show-more-active': + '', + 'show-more-inactive': + '', }; diff --git a/src/widgets/hierarchical-menu/hierarchical-menu.js b/src/widgets/hierarchical-menu/hierarchical-menu.js index cad20eb166..62ece493c8 100644 --- a/src/widgets/hierarchical-menu/hierarchical-menu.js +++ b/src/widgets/hierarchical-menu/hierarchical-menu.js @@ -18,11 +18,20 @@ const renderer = ({ collapsible, cssClasses, containerNode, + showMore, transformData, templates, renderState, }) => ( - { createURL, items, refine, instantSearchInstance }, + { + createURL, + items, + refine, + instantSearchInstance, + isShowingMore, + toggleShowMore, + canToggleShowMore, + }, isFirstRendering ) => { if (isFirstRendering) { @@ -46,6 +55,10 @@ const renderer = ({ shouldAutoHideContainer={shouldAutoHideContainer} templateProps={renderState.templateProps} toggleRefinement={refine} + showMore={showMore} + toggleShowMore={toggleShowMore} + isShowingMore={isShowingMore} + canToggleShowMore={canToggleShowMore} />, containerNode ); @@ -201,6 +214,8 @@ export default function hierarchicalMenu({ autoHideContainer = true, templates = defaultTemplates, collapsible = false, + showMore = false, + showMoreLimit, transformData, transformItems, } = {}) { @@ -230,6 +245,7 @@ export default function hierarchicalMenu({ containerNode, transformData, templates, + showMore, renderState: {}, }); @@ -244,6 +260,7 @@ export default function hierarchicalMenu({ rootPath, showParentLevel, limit, + showMoreLimit, sortBy, transformItems, });