diff --git a/dev/app/builtin/stories/breadcrumb.stories.js b/dev/app/builtin/stories/breadcrumb.stories.js index 9961c0ede8..ff62023538 100644 --- a/dev/app/builtin/stories/breadcrumb.stories.js +++ b/dev/app/builtin/stories/breadcrumb.stories.js @@ -135,5 +135,40 @@ export default () => { }) ); }) + ) + .add( + 'with transformed items', + wrapWithHits(container => { + container.innerHTML = ` +
+ + `; + + window.search.addWidget( + instantsearch.widgets.breadcrumb({ + container: '#breadcrumb', + attributes: [ + 'hierarchicalCategories.lvl0', + 'hierarchicalCategories.lvl1', + 'hierarchicalCategories.lvl2', + ], + transformItems: items => + items.map(item => ({ + ...item, + name: `${item.name} (transformed)`, + })), + }) + ); + + // Custom Widget to toggle refinement + window.search.addWidget({ + init({ helper }) { + helper.toggleRefinement( + 'hierarchicalCategories.lvl0', + 'Cameras & Camcorders > Digital Cameras' + ); + }, + }); + }) ); }; diff --git a/dev/app/builtin/stories/current-refined-values.stories.js b/dev/app/builtin/stories/current-refined-values.stories.js index 7a4b00c845..96290f6942 100644 --- a/dev/app/builtin/stories/current-refined-values.stories.js +++ b/dev/app/builtin/stories/current-refined-values.stories.js @@ -80,5 +80,30 @@ export default () => { }, } ) + ) + .add( + 'with transformed items', + wrapWithHits( + container => { + window.search.addWidget( + instantsearch.widgets.currentRefinedValues({ + container, + clearsQuery: true, + transformItems: items => + items.map(item => ({ + ...item, + name: `${item.name} (transformed)`, + })), + }) + ); + }, + { + searchParameters: { + disjunctiveFacetsRefinements: { brand: ['Apple', 'Samsung'] }, + disjunctiveFacets: ['brand'], + numericRefinements: { price: { '>=': [100] } }, + }, + } + ) ); }; diff --git a/dev/app/builtin/stories/geo-search.stories.js b/dev/app/builtin/stories/geo-search.stories.js index 65c473d1dc..5077f0c9d5 100644 --- a/dev/app/builtin/stories/geo-search.stories.js +++ b/dev/app/builtin/stories/geo-search.stories.js @@ -61,6 +61,30 @@ export default () => { }) ); + start(); + }) + ) + ).add( + 'with transformed items', + wrapWithHitsAndConfiguration((container, start) => + injectGoogleMaps(() => { + container.style.height = '600px'; + + window.search.addWidget( + instantsearch.widgets.geoSearch({ + googleReference: window.google, + container, + transformItems: items => + items.map(item => ({ + ...item, + _geoloc: { + lat: item._geoloc.lat + 2, + lng: item._geoloc.lng + 2, + }, + })), + }) + ); + start(); }) ) diff --git a/dev/app/builtin/stories/hierarchical-menu.stories.js b/dev/app/builtin/stories/hierarchical-menu.stories.js index 7916dc1b19..c9de784c8e 100644 --- a/dev/app/builtin/stories/hierarchical-menu.stories.js +++ b/dev/app/builtin/stories/hierarchical-menu.stories.js @@ -84,5 +84,25 @@ export default () => { }) ); }) + ) + .add( + 'with transformed items', + wrapWithHits(container => { + window.search.addWidget( + instantsearch.widgets.hierarchicalMenu({ + container, + attributes: [ + 'hierarchicalCategories.lvl0', + 'hierarchicalCategories.lvl1', + 'hierarchicalCategories.lvl2', + ], + transformItems: items => + items.map(item => ({ + ...item, + label: `${item.label} (transformed)`, + })), + }) + ); + }) ); }; diff --git a/dev/app/builtin/stories/hits-per-page-selector.stories.js b/dev/app/builtin/stories/hits-per-page-selector.stories.js index 79d9919599..9ab9e53617 100644 --- a/dev/app/builtin/stories/hits-per-page-selector.stories.js +++ b/dev/app/builtin/stories/hits-per-page-selector.stories.js @@ -37,5 +37,25 @@ export default () => { }) ); }) + ) + .add( + 'with transformed items', + wrapWithHits(container => { + window.search.addWidget( + instantsearch.widgets.hitsPerPageSelector({ + container, + items: [ + { value: 3, label: '3 per page' }, + { value: 5, label: '5 per page' }, + { value: 10, label: '10 per page' }, + ], + transformItems: items => + items.map(item => ({ + ...item, + label: `${item.label} (transformed)`, + })), + }) + ); + }) ); }; diff --git a/dev/app/builtin/stories/hits.stories.js b/dev/app/builtin/stories/hits.stories.js index 7e34f94a28..435c3090bc 100644 --- a/dev/app/builtin/stories/hits.stories.js +++ b/dev/app/builtin/stories/hits.stories.js @@ -14,6 +14,21 @@ export default () => { window.search.addWidget(instantsearch.widgets.hits({ container })); }) ) + .add( + 'with transformed items', + wrapWithHits(container => { + window.search.addWidget( + instantsearch.widgets.hits({ + container, + transformItems: items => + items.map(item => ({ + ...item, + name: `${item.name} (transformed)`, + })), + }) + ); + }) + ) .add( 'with highlighted array', wrapWithHits( diff --git a/dev/app/builtin/stories/infinite-hits.stories.js b/dev/app/builtin/stories/infinite-hits.stories.js index 9d7c2bbea1..ca0c519b47 100644 --- a/dev/app/builtin/stories/infinite-hits.stories.js +++ b/dev/app/builtin/stories/infinite-hits.stories.js @@ -44,5 +44,24 @@ export default () => { }) ); }) + ) + .add( + 'with transformed items', + wrapWithHits(container => { + window.search.addWidget( + instantsearch.widgets.infiniteHits({ + container, + showMoreLabel: 'Show more', + templates: { + item: '{{name}}', + }, + transformItems: items => + items.map(item => ({ + ...item, + name: `${item.name} (transformed)`, + })), + }) + ); + }) ); }; diff --git a/dev/app/builtin/stories/menu.stories.js b/dev/app/builtin/stories/menu.stories.js index 940c8e9682..700917fbfa 100644 --- a/dev/app/builtin/stories/menu.stories.js +++ b/dev/app/builtin/stories/menu.stories.js @@ -19,6 +19,22 @@ export default () => { ); }) ) + .add( + 'with transformed items', + wrapWithHits(container => { + window.search.addWidget( + instantsearch.widgets.menu({ + container, + attributeName: 'categories', + transformItems: items => + items.map(item => ({ + ...item, + label: `${item.label} (transformed)`, + })), + }) + ); + }) + ) .add( 'with show more and header', wrapWithHits(container => { diff --git a/dev/app/builtin/stories/numeric-refinement-list.stories.js b/dev/app/builtin/stories/numeric-refinement-list.stories.js index d8dca85c5b..40bc747bf0 100644 --- a/dev/app/builtin/stories/numeric-refinement-list.stories.js +++ b/dev/app/builtin/stories/numeric-refinement-list.stories.js @@ -7,32 +7,66 @@ import { wrapWithHits } from '../../utils/wrap-with-hits.js'; const stories = storiesOf('NumericRefinementList'); export default () => { - stories.add( - 'default', - wrapWithHits(container => { - window.search.addWidget( - instantsearch.widgets.numericRefinementList({ - container, - attributeName: 'price', - operator: 'or', - options: [ - { name: 'All' }, - { end: 4, name: 'less than 4' }, - { start: 4, end: 4, name: '4' }, - { start: 5, end: 10, name: 'between 5 and 10' }, - { start: 10, name: 'more than 10' }, - ], - cssClasses: { - header: 'facet-title', - link: 'facet-value', - count: 'facet-count pull-right', - active: 'facet-active', - }, - templates: { - header: 'Numeric refinement list (price)', - }, - }) - ); - }) - ); + stories + .add( + 'default', + wrapWithHits(container => { + window.search.addWidget( + instantsearch.widgets.numericRefinementList({ + container, + attributeName: 'price', + operator: 'or', + options: [ + { name: 'All' }, + { end: 4, name: 'less than 4' }, + { start: 4, end: 4, name: '4' }, + { start: 5, end: 10, name: 'between 5 and 10' }, + { start: 10, name: 'more than 10' }, + ], + cssClasses: { + header: 'facet-title', + link: 'facet-value', + count: 'facet-count pull-right', + active: 'facet-active', + }, + templates: { + header: 'Numeric refinement list (price)', + }, + }) + ); + }) + ) + .add( + 'with transformed hits', + wrapWithHits(container => { + window.search.addWidget( + instantsearch.widgets.numericRefinementList({ + container, + attributeName: 'price', + operator: 'or', + options: [ + { name: 'All' }, + { end: 4, name: 'less than 4' }, + { start: 4, end: 4, name: '4' }, + { start: 5, end: 10, name: 'between 5 and 10' }, + { start: 10, name: 'more than 10' }, + ], + cssClasses: { + header: 'facet-title', + link: 'facet-value', + count: 'facet-count pull-right', + active: 'facet-active', + }, + templates: { + header: 'Numeric refinement list (price)', + }, + transformItems: items => + items.map(item => ({ + ...item, + label: `${item.label} (transformed)`, + })), + }) + ); + }) + ); }; diff --git a/dev/app/builtin/stories/numeric-selector.stories.js b/dev/app/builtin/stories/numeric-selector.stories.js index 0d16363620..ad9ad21f36 100644 --- a/dev/app/builtin/stories/numeric-selector.stories.js +++ b/dev/app/builtin/stories/numeric-selector.stories.js @@ -45,4 +45,29 @@ export default () => { ); }) ); + stories.add( + 'with transformed items', + wrapWithHits(container => { + window.search.addWidget( + instantsearch.widgets.numericSelector({ + container, + operator: '=', + attributeName: 'rating', + options: [ + { label: 'No rating selected', value: undefined }, + { label: 'Rating: 5', value: 5 }, + { label: 'Rating: 4', value: 4 }, + { label: 'Rating: 3', value: 3 }, + { label: 'Rating: 2', value: 2 }, + { label: 'Rating: 1', value: 1 }, + ], + transformItems: items => + items.map(item => ({ + ...item, + label: `${item.label} (transformed)`, + })), + }) + ); + }) + ); }; diff --git a/dev/app/builtin/stories/refinement-list.stories.js b/dev/app/builtin/stories/refinement-list.stories.js index b6f952cc8c..979a6acc6c 100644 --- a/dev/app/builtin/stories/refinement-list.stories.js +++ b/dev/app/builtin/stories/refinement-list.stories.js @@ -115,5 +115,55 @@ export default () => { }) ); }) + ) + .add( + 'with transformed items', + wrapWithHits(container => { + window.search.addWidget( + instantsearch.widgets.refinementList({ + container, + attributeName: 'brand', + operator: 'or', + limit: 10, + templates: { + header: 'Transformed brands', + }, + transformItems: items => + items.map(item => ({ + ...item, + label: `${item.label} (transformed)`, + highlighted: `${item.highlighted} (transformed)`, + })), + }) + ); + }) + ) + .add( + 'with searchable transformed items', + wrapWithHits(container => { + window.search.addWidget( + instantsearch.widgets.refinementList({ + container, + attributeName: 'brand', + operator: 'or', + limit: 10, + templates: { + header: 'Transformed searchable brands', + }, + searchForFacetValues: { + placeholder: 'Find other brands...', + templates: { + noResults: 'No results', + }, + }, + transformItems: items => + items.map(item => ({ + ...item, + label: `${item.label} (transformed)`, + highlighted: `${item.highlighted} (transformed)`, + })), + }) + ); + }) ); }; diff --git a/dev/app/builtin/stories/sort-by-selector.stories.js b/dev/app/builtin/stories/sort-by-selector.stories.js index 7a87a18131..cbdc3df1a3 100644 --- a/dev/app/builtin/stories/sort-by-selector.stories.js +++ b/dev/app/builtin/stories/sort-by-selector.stories.js @@ -7,19 +7,40 @@ import { wrapWithHits } from '../../utils/wrap-with-hits.js'; const stories = storiesOf('SortBySelector'); export default () => { - stories.add( - 'default', - wrapWithHits(container => { - window.search.addWidget( - instantsearch.widgets.sortBySelector({ - container, - indices: [ - { name: 'instant_search', label: 'Most relevant' }, - { name: 'instant_search_price_asc', label: 'Lowest price' }, - { name: 'instant_search_price_desc', label: 'Highest price' }, - ], - }) - ); - }) - ); + stories + .add( + 'default', + wrapWithHits(container => { + window.search.addWidget( + instantsearch.widgets.sortBySelector({ + container, + indices: [ + { name: 'instant_search', label: 'Most relevant' }, + { name: 'instant_search_price_asc', label: 'Lowest price' }, + { name: 'instant_search_price_desc', label: 'Highest price' }, + ], + }) + ); + }) + ) + .add( + 'with transformed items', + wrapWithHits(container => { + window.search.addWidget( + instantsearch.widgets.sortBySelector({ + container, + indices: [ + { name: 'instant_search', label: 'Most relevant' }, + { name: 'instant_search_price_asc', label: 'Lowest price' }, + { name: 'instant_search_price_desc', label: 'Highest price' }, + ], + transformItems: items => + items.map(item => ({ + ...item, + label: `${item.label} (transformed)`, + })), + }) + ); + }) + ); }; diff --git a/src/connectors/breadcrumb/__tests__/connectBreadcrumb-test.js b/src/connectors/breadcrumb/__tests__/connectBreadcrumb-test.js index 9a03124468..05ed133b09 100644 --- a/src/connectors/breadcrumb/__tests__/connectBreadcrumb-test.js +++ b/src/connectors/breadcrumb/__tests__/connectBreadcrumb-test.js @@ -202,6 +202,64 @@ describe('connectBreadcrumb', () => { ]); }); + it('provides the correct facet values when transformed', () => { + const rendering = jest.fn(); + const makeWidget = connectBreadcrumb(rendering); + const widget = makeWidget({ + attributes: ['category', 'sub_category'], + transformItems: items => + items.map(item => ({ ...item, name: 'transformed' })), + }); + + const config = widget.getConfiguration({}); + const helper = jsHelper({}, '', config); + helper.search = jest.fn(); + + helper.toggleRefinement('category', 'Decoration'); + + widget.init({ + helper, + state: helper.state, + createURL: () => '#', + }); + + const firstRenderingOptions = rendering.mock.calls[0][0]; + expect(firstRenderingOptions.items).toEqual([]); + + widget.render({ + results: new SearchResults(helper.state, [ + { + hits: [], + facets: { + category: { + Decoration: 880, + }, + subCategory: { + 'Decoration > Candle holders & candles': 193, + 'Decoration > Frames & pictures': 173, + }, + }, + }, + { + facets: { + category: { + Decoration: 880, + Outdoor: 47, + }, + }, + }, + ]), + state: helper.state, + helper, + createURL: () => '#', + }); + + const secondRenderingOptions = rendering.mock.calls[1][0]; + expect(secondRenderingOptions.items).toEqual([ + expect.objectContaining({ name: 'transformed' }), + ]); + }); + it('returns the correct URL', () => { const rendering = jest.fn(); const makeWidget = connectBreadcrumb(rendering); diff --git a/src/connectors/breadcrumb/connectBreadcrumb.js b/src/connectors/breadcrumb/connectBreadcrumb.js index 2be5a76c72..f1ce40420e 100644 --- a/src/connectors/breadcrumb/connectBreadcrumb.js +++ b/src/connectors/breadcrumb/connectBreadcrumb.js @@ -16,6 +16,7 @@ search.addWidget( customBreadcrumb({ attributes, [ rootPath = null ], + [ transformItems ] }) ); Full documentation available at https://community.algolia.com/instantsearch.js/v2/connectors/connectBreadcrumb.html @@ -31,6 +32,7 @@ Full documentation available at https://community.algolia.com/instantsearch.js/v * @typedef {Object} CustomBreadcrumbWidgetOptions * @property {string[]} attributes Attributes to use to generate the hierarchy of the breadcrumb. * @property {string} [rootPath = null] Prefix path to use if the first level is not the root level. + * @property {function(object[]):object[]} [transformItems] Function to transform the items passed to the templates. * * 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). */ @@ -56,7 +58,12 @@ Full documentation available at https://community.algolia.com/instantsearch.js/v export default function connectBreadcrumb(renderFn, unmountFn) { checkRendering(renderFn, usage); return (widgetParams = {}) => { - const { attributes, separator = ' > ', rootPath = null } = widgetParams; + const { + attributes, + separator = ' > ', + rootPath = null, + transformItems = items => items, + } = widgetParams; const [hierarchicalFacetName] = attributes; if (!attributes || !Array.isArray(attributes) || attributes.length === 0) { @@ -148,7 +155,9 @@ export default function connectBreadcrumb(renderFn, unmountFn) { const [{ name: facetName }] = state.hierarchicalFacets; const facetsValues = results.getFacetValues(facetName); - const items = shiftItemsValues(prepareItems(facetsValues)); + const items = transformItems( + shiftItemsValues(prepareItems(facetsValues)) + ); renderFn( { diff --git a/src/connectors/current-refined-values/__tests__/connectCurrentRefinedValues-test.js b/src/connectors/current-refined-values/__tests__/connectCurrentRefinedValues-test.js index 2384896ebc..23e54febb6 100644 --- a/src/connectors/current-refined-values/__tests__/connectCurrentRefinedValues-test.js +++ b/src/connectors/current-refined-values/__tests__/connectCurrentRefinedValues-test.js @@ -54,6 +54,44 @@ describe('connectCurrentRefinedValues', () => { }); }); + it('Renders transformed items during init and render', () => { + const helper = jsHelper({}, '', { + facets: ['myFacet'], + }); + helper.search = () => {}; + const rendering = jest.fn(); + const makeWidget = connectCurrentRefinedValues(rendering); + const widget = makeWidget({ + transformItems: items => + items.map(item => ({ ...item, name: 'transformed' })), + }); + + helper.addFacetRefinement('myFacet', 'value'); + + widget.init({ + helper, + state: helper.state, + createURL: () => '#', + }); + + const firstRenderingOptions = rendering.mock.calls[0][0]; + expect(firstRenderingOptions.refinements).toEqual([ + expect.objectContaining({ name: 'transformed' }), + ]); + + widget.render({ + results: new SearchResults(helper.state, [{}]), + state: helper.state, + helper, + createURL: () => '#', + }); + + const secondRenderingOptions = rendering.mock.calls[0][0]; + expect(secondRenderingOptions.refinements).toEqual([ + expect.objectContaining({ name: 'transformed' }), + ]); + }); + it('Provide a function to clear the refinement', () => { // For each refinements we get a function that we can call // for removing a single refinement diff --git a/src/connectors/current-refined-values/connectCurrentRefinedValues.js b/src/connectors/current-refined-values/connectCurrentRefinedValues.js index a36be3a0e6..cfcb7787df 100644 --- a/src/connectors/current-refined-values/connectCurrentRefinedValues.js +++ b/src/connectors/current-refined-values/connectCurrentRefinedValues.js @@ -34,7 +34,8 @@ search.addWidget( customCurrentRefinedValues({ [ attributes = [] ], [ onlyListedAttributes = false ], - [ clearsQuery = false ] + [ clearsQuery = false ], + [ transformItems ], }) ); Full documentation available at https://community.algolia.com/instantsearch.js/v2/connectors/connectCurrentRefinedValues.html @@ -75,6 +76,7 @@ Full documentation available at https://community.algolia.com/instantsearch.js/v * set with no special treatment for the label. * @property {boolean} [onlyListedAttributes = false] Limit the displayed refinement to the list specified. * @property {boolean} [clearsQuery = false] Clears also the active search query when using clearAll. + * @property {function(object[]):object[]} [transformItems] Function to transform the items passed to the templates. */ /** @@ -157,6 +159,7 @@ export default function connectCurrentRefinedValues(renderFn, unmountFn) { attributes = [], onlyListedAttributes = false, clearsQuery = false, + transformItems = items => items, } = widgetParams; const attributesOK = @@ -216,12 +219,14 @@ export default function connectCurrentRefinedValues(renderFn, unmountFn) { clearRefinements({ helper, whiteList: restrictedTo, clearsQuery }) ); - const refinements = getFilteredRefinements( - {}, - helper.state, - attributeNames, - onlyListedAttributes, - clearsQuery + const refinements = transformItems( + getFilteredRefinements( + {}, + helper.state, + attributeNames, + onlyListedAttributes, + clearsQuery + ) ); const _createURL = refinement => @@ -245,12 +250,14 @@ export default function connectCurrentRefinedValues(renderFn, unmountFn) { }, render({ results, helper, state, createURL, instantSearchInstance }) { - const refinements = getFilteredRefinements( - results, - state, - attributeNames, - onlyListedAttributes, - clearsQuery + const refinements = transformItems( + getFilteredRefinements( + results, + state, + attributeNames, + onlyListedAttributes, + clearsQuery + ) ); const _createURL = refinement => diff --git a/src/connectors/geo-search/__tests__/connectGeoSearch-test.js b/src/connectors/geo-search/__tests__/connectGeoSearch-test.js index c9fc8518f6..3eafaa21be 100644 --- a/src/connectors/geo-search/__tests__/connectGeoSearch-test.js +++ b/src/connectors/geo-search/__tests__/connectGeoSearch-test.js @@ -175,6 +175,48 @@ describe('connectGeoSearch - rendering', () => { ); }); + it('expect to render with transformed hits', () => { + const render = jest.fn(); + const unmount = jest.fn(); + + const customGeoSearch = connectGeoSearch(render, unmount); + const widget = customGeoSearch({ + transformItems: items => + items.map(item => ({ + ...item, + _geoloc: { + lat: 20, + lng: 20, + }, + })), + }); + + const helper = createFakeHelper({}); + + widget.render({ + results: new SearchResults(helper.getState(), [ + { + hits: [ + { objectID: 123, _geoloc: { lat: 10, lng: 12 } }, + { objectID: 456 }, + { objectID: 789, _geoloc: { lat: 10, lng: 12 } }, + ], + }, + ]), + helper, + }); + + expect(render).toHaveBeenLastCalledWith( + expect.objectContaining({ + items: [ + { objectID: 123, _geoloc: { lat: 20, lng: 20 } }, + { objectID: 789, _geoloc: { lat: 20, lng: 20 } }, + ], + }), + false + ); + }); + it('expect to render with position from the state', () => { const render = jest.fn(); const unmount = jest.fn(); diff --git a/src/connectors/geo-search/connectGeoSearch.js b/src/connectors/geo-search/connectGeoSearch.js index 4dc3c4344f..17c293056d 100644 --- a/src/connectors/geo-search/connectGeoSearch.js +++ b/src/connectors/geo-search/connectGeoSearch.js @@ -27,6 +27,7 @@ search.addWidget( [ position ], [ radius ], [ precision ], + [ transformItems ], }) ); @@ -55,6 +56,7 @@ Full documentation available at https://community.algolia.com/instantsearch.js/v * See [the documentation](https://www.algolia.com/doc/api-reference/api-parameters/aroundRadius) for more informations. * @property {number} [precision] Precision of geo search (in meters).
* See [the documentation](https://www.algolia.com/doc/api-reference/api-parameters/aroundPrecision) for more informations. + * @property {function(object[]):object[]} [transformItems] Function to transform the items passed to the templates. */ /** @@ -137,6 +139,7 @@ const connectGeoSearch = (renderFn, unmountFn) => { position, radius, precision, + transformItems = items => items, } = widgetParams; const widgetState = { @@ -258,9 +261,11 @@ const connectGeoSearch = (renderFn, unmountFn) => { renderArgs ); + const items = transformItems(results.hits.filter(hit => hit._geoloc)); + renderFn( { - items: results.hits.filter(hit => hit._geoloc), + items, position: getPositionFromState(state), refine: refine(helper), clearMapRefinement: clearMapRefinement(helper), diff --git a/src/connectors/hierarchical-menu/__tests__/connectHierarchicalMenu-test.js b/src/connectors/hierarchical-menu/__tests__/connectHierarchicalMenu-test.js index 99895a54ab..2d7c40802b 100644 --- a/src/connectors/hierarchical-menu/__tests__/connectHierarchicalMenu-test.js +++ b/src/connectors/hierarchical-menu/__tests__/connectHierarchicalMenu-test.js @@ -1,5 +1,3 @@ -import sinon from 'sinon'; - import jsHelper from 'algoliasearch-helper'; const SearchResults = jsHelper.SearchResults; const SearchParameters = jsHelper.SearchParameters; @@ -65,7 +63,7 @@ describe('connectHierarchicalMenu', () => { it('Renders during init and render', () => { // test that the dummyRendering is called with the isFirstRendering // flag set accordingly - const rendering = sinon.stub(); + const rendering = jest.fn(); const makeWidget = connectHierarchicalMenu(rendering); const widget = makeWidget({ attributes: ['category', 'sub_category'], @@ -86,10 +84,10 @@ describe('connectHierarchicalMenu', () => { }); // test if widget is not rendered yet at this point - expect(rendering.callCount).toBe(0); + expect(rendering).toHaveBeenCalledTimes(0); const helper = jsHelper({}, '', config); - helper.search = sinon.stub(); + helper.search = jest.fn(); widget.init({ helper, @@ -99,12 +97,14 @@ describe('connectHierarchicalMenu', () => { }); // test that rendering has been called during init with isFirstRendering = true - expect(rendering.callCount).toBe(1); + expect(rendering).toHaveBeenCalledTimes(1); // test if isFirstRendering is true during init - expect(rendering.lastCall.args[1]).toBe(true); - expect(rendering.lastCall.args[0].widgetParams).toEqual({ - attributes: ['category', 'sub_category'], - }); + expect(rendering).toHaveBeenLastCalledWith( + expect.objectContaining({ + widgetParams: { attributes: ['category', 'sub_category'] }, + }), + true + ); widget.render({ results: new SearchResults(helper.state, [{}]), @@ -114,22 +114,24 @@ describe('connectHierarchicalMenu', () => { }); // test that rendering has been called during init with isFirstRendering = false - expect(rendering.callCount).toBe(2); - expect(rendering.lastCall.args[1]).toBe(false); - expect(rendering.lastCall.args[0].widgetParams).toEqual({ - attributes: ['category', 'sub_category'], - }); + expect(rendering).toHaveBeenCalledTimes(2); + expect(rendering).toHaveBeenLastCalledWith( + expect.objectContaining({ + widgetParams: { attributes: ['category', 'sub_category'] }, + }), + false + ); }); it('Provide a function to clear the refinements at each step', () => { - const rendering = sinon.stub(); + const rendering = jest.fn(); const makeWidget = connectHierarchicalMenu(rendering); const widget = makeWidget({ attributes: ['category', 'sub_category'], }); const helper = jsHelper({}, '', widget.getConfiguration({})); - helper.search = sinon.stub(); + helper.search = jest.fn(); helper.toggleRefinement('category', 'value'); @@ -140,7 +142,7 @@ describe('connectHierarchicalMenu', () => { onHistoryChange: () => {}, }); - const firstRenderingOptions = rendering.lastCall.args[0]; + const firstRenderingOptions = rendering.mock.calls[0][0]; const { refine } = firstRenderingOptions; refine('value'); expect(helper.hasRefinements('category')).toBe(false); @@ -154,7 +156,7 @@ describe('connectHierarchicalMenu', () => { createURL: () => '#', }); - const secondRenderingOptions = rendering.lastCall.args[0]; + const secondRenderingOptions = rendering.mock.calls[1][0]; const { refine: renderToggleRefinement } = secondRenderingOptions; renderToggleRefinement('value'); expect(helper.hasRefinements('category')).toBe(false); @@ -163,14 +165,14 @@ describe('connectHierarchicalMenu', () => { }); it('provides the correct facet values', () => { - const rendering = sinon.stub(); + const rendering = jest.fn(); const makeWidget = connectHierarchicalMenu(rendering); const widget = makeWidget({ attributes: ['category', 'subCategory'], }); const helper = jsHelper({}, '', widget.getConfiguration({})); - helper.search = sinon.stub(); + helper.search = jest.fn(); helper.toggleRefinement('category', 'Decoration'); @@ -181,11 +183,15 @@ describe('connectHierarchicalMenu', () => { onHistoryChange: () => {}, }); - const firstRenderingOptions = rendering.lastCall.args[0]; // During the first rendering there are no facet values // The function get an empty array so that it doesn't break // over null-ish values. - expect(firstRenderingOptions.items).toEqual([]); + expect(rendering).toHaveBeenLastCalledWith( + expect.objectContaining({ + items: [], + }), + expect.anything() + ); widget.render({ results: new SearchResults(helper.state, [ @@ -215,38 +221,107 @@ describe('connectHierarchicalMenu', () => { createURL: () => '#', }); - const secondRenderingOptions = rendering.lastCall.args[0]; - expect(secondRenderingOptions.items).toEqual([ - { - label: 'Decoration', - value: 'Decoration', - count: 880, - isRefined: true, - data: [ + expect(rendering).toHaveBeenLastCalledWith( + expect.objectContaining({ + items: [ { - label: 'Candle holders & candles', - value: 'Decoration > Candle holders & candles', - count: 193, - isRefined: false, - data: null, + label: 'Decoration', + value: 'Decoration', + count: 880, + isRefined: true, + data: [ + { + label: 'Candle holders & candles', + value: 'Decoration > Candle holders & candles', + count: 193, + isRefined: false, + data: null, + }, + { + label: 'Frames & pictures', + value: 'Decoration > Frames & pictures', + count: 173, + isRefined: false, + data: null, + }, + ], }, { - label: 'Frames & pictures', - value: 'Decoration > Frames & pictures', - count: 173, + label: 'Outdoor', + value: 'Outdoor', + count: 47, isRefined: false, data: null, }, ], - }, - { - label: 'Outdoor', - value: 'Outdoor', - count: 47, - isRefined: false, - data: null, - }, - ]); + }), + expect.anything() + ); + }); + + it('provides the correct transformed facet values', () => { + const rendering = jest.fn(); + const makeWidget = connectHierarchicalMenu(rendering); + const widget = makeWidget({ + attributes: ['category', 'subCategory'], + transformItems: items => + items.map(item => ({ + ...item, + label: 'transformed', + })), + }); + + const helper = jsHelper({}, '', widget.getConfiguration({})); + helper.search = jest.fn(); + + helper.toggleRefinement('category', 'Decoration'); + + widget.init({ + helper, + state: helper.state, + }); + + expect(rendering).toHaveBeenLastCalledWith( + expect.objectContaining({ items: [] }), + expect.anything() + ); + + widget.render({ + results: new SearchResults(helper.state, [ + { + hits: [], + facets: { + category: { + Decoration: 880, + }, + subCategory: { + 'Decoration > Candle holders & candles': 193, + 'Decoration > Frames & pictures': 173, + }, + }, + }, + { + facets: { + category: { + Decoration: 880, + Outdoor: 47, + }, + }, + }, + ]), + state: helper.state, + helper, + }); + + expect(rendering).toHaveBeenLastCalledWith( + expect.objectContaining({ + items: [ + expect.objectContaining({ label: 'transformed' }), + expect.objectContaining({ label: 'transformed' }), + ], + }), + expect.anything() + ); }); describe('routing', () => { @@ -258,7 +333,7 @@ describe('connectHierarchicalMenu', () => { }); const helper = jsHelper({}, '', widget.getConfiguration({})); - helper.search = sinon.stub(); + helper.search = jest.fn(); widget.init({ helper, diff --git a/src/connectors/hierarchical-menu/connectHierarchicalMenu.js b/src/connectors/hierarchical-menu/connectHierarchicalMenu.js index e966ca0796..e2562b0338 100644 --- a/src/connectors/hierarchical-menu/connectHierarchicalMenu.js +++ b/src/connectors/hierarchical-menu/connectHierarchicalMenu.js @@ -21,6 +21,7 @@ search.addWidget( [ showParentLevel = true ], [ limit = 10 ], [ sortBy = ['name:asc'] ], + [ transformItems ], }) ); Full documentation available at https://community.algolia.com/instantsearch.js/v2/connectors/connectHierarchicalMenu.html @@ -46,6 +47,7 @@ Full documentation available at https://community.algolia.com/instantsearch.js/v * @property {string[]|function} [sortBy = ['name:asc']] 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). + * @property {function(object[]):object[]} [transformItems] Function to transform the items passed to the templates. */ /** @@ -82,6 +84,7 @@ export default function connectHierarchicalMenu(renderFn, unmountFn) { showParentLevel = true, limit = 10, sortBy = ['name:asc'], + transformItems = items => items, } = widgetParams; if (!attributes || !attributes.length) { @@ -168,9 +171,12 @@ export default function connectHierarchicalMenu(renderFn, unmountFn) { }, render({ results, state, createURL, instantSearchInstance }) { - const items = this._prepareFacetValues( - results.getFacetValues(hierarchicalFacetName, { sortBy }).data || [], - state + const items = transformItems( + this._prepareFacetValues( + results.getFacetValues(hierarchicalFacetName, { sortBy }).data || + [], + state + ) ); // Bind createURL to this specific attribute diff --git a/src/connectors/hits-per-page/__tests__/connectHitsPerPage-test.js b/src/connectors/hits-per-page/__tests__/connectHitsPerPage-test.js index 96772b61a9..b024b8f6b7 100644 --- a/src/connectors/hits-per-page/__tests__/connectHitsPerPage-test.js +++ b/src/connectors/hits-per-page/__tests__/connectHitsPerPage-test.js @@ -1,4 +1,3 @@ -import sinon from 'sinon'; import jsHelper from 'algoliasearch-helper'; const SearchResults = jsHelper.SearchResults; const SearchParameters = jsHelper.SearchParameters; @@ -20,7 +19,7 @@ describe('connectHitsPerPage', () => { it('Renders during init and render', () => { // test that the dummyRendering is called with the isFirstRendering // flag set accordingly - const rendering = sinon.stub(); + const rendering = jest.fn(); const makeWidget = connectHitsPerPage(rendering); const widget = makeWidget({ items: [ @@ -33,12 +32,12 @@ describe('connectHitsPerPage', () => { expect(widget.getConfiguration()).toEqual({}); // test if widget is not rendered yet at this point - expect(rendering.callCount).toBe(0); + expect(rendering).toHaveBeenCalledTimes(0); const helper = jsHelper({}, '', { hitsPerPage: 3, }); - helper.search = sinon.stub(); + helper.search = jest.fn(); widget.init({ helper, @@ -48,15 +47,19 @@ describe('connectHitsPerPage', () => { }); // test that rendering has been called during init with isFirstRendering = true - expect(rendering.callCount).toBe(1); + expect(rendering).toHaveBeenCalledTimes(1); // test if isFirstRendering is true during init - expect(rendering.lastCall.args[1]).toBe(true); - expect(rendering.lastCall.args[0].widgetParams).toEqual({ - items: [ - { value: 3, label: '3 items per page' }, - { value: 10, label: '10 items per page' }, - ], - }); + expect(rendering).toHaveBeenLastCalledWith( + expect.objectContaining({ + widgetParams: { + items: [ + { value: 3, label: '3 items per page' }, + { value: 10, label: '10 items per page' }, + ], + }, + }), + true + ); widget.render({ results: new SearchResults(helper.state, [{}]), @@ -66,18 +69,71 @@ describe('connectHitsPerPage', () => { }); // test that rendering has been called during init with isFirstRendering = false - expect(rendering.callCount).toBe(2); - expect(rendering.lastCall.args[1]).toBe(false); - expect(rendering.lastCall.args[0].widgetParams).toEqual({ + expect(rendering).toHaveBeenCalledTimes(2); + expect(rendering).toHaveBeenLastCalledWith( + expect.objectContaining({ + widgetParams: { + items: [ + { value: 3, label: '3 items per page' }, + { value: 10, label: '10 items per page' }, + ], + }, + }), + false + ); + }); + + it('Renders during init and render with transformed items', () => { + const rendering = jest.fn(); + const makeWidget = connectHitsPerPage(rendering); + const widget = makeWidget({ items: [ { value: 3, label: '3 items per page' }, { value: 10, label: '10 items per page' }, ], + transformItems: items => + items.map(item => ({ ...item, label: 'transformed' })), + }); + + const helper = jsHelper({}, '', { + hitsPerPage: 3, + }); + helper.search = jest.fn(); + + widget.init({ + helper, + state: helper.state, }); + + expect(rendering).toHaveBeenLastCalledWith( + expect.objectContaining({ + items: [ + expect.objectContaining({ label: 'transformed' }), + expect.objectContaining({ label: 'transformed' }), + ], + }), + true + ); + + widget.render({ + results: new SearchResults(helper.state, [{}]), + state: helper.state, + helper, + }); + + expect(rendering).toHaveBeenLastCalledWith( + expect.objectContaining({ + items: [ + expect.objectContaining({ label: 'transformed' }), + expect.objectContaining({ label: 'transformed' }), + ], + }), + false + ); }); it('Configures the search with the default hitsPerPage provided', () => { - const rendering = sinon.stub(); + const rendering = jest.fn(); const makeWidget = connectHitsPerPage(rendering); const widget = makeWidget({ items: [ @@ -92,7 +148,7 @@ describe('connectHitsPerPage', () => { }); it('Does not configures the search when there is no default value', () => { - const rendering = sinon.stub(); + const rendering = jest.fn(); const makeWidget = connectHitsPerPage(rendering); const widget = makeWidget({ items: [ @@ -105,7 +161,7 @@ describe('connectHitsPerPage', () => { }); it('Provide a function to change the current hits per page, and provide the current value', () => { - const rendering = sinon.stub(); + const rendering = jest.fn(); const makeWidget = connectHitsPerPage(rendering); const widget = makeWidget({ items: [ @@ -118,7 +174,7 @@ describe('connectHitsPerPage', () => { const helper = jsHelper({}, '', { hitsPerPage: 11, }); - helper.search = sinon.stub(); + helper.search = jest.fn(); widget.init({ helper, @@ -127,7 +183,7 @@ describe('connectHitsPerPage', () => { onHistoryChange: () => {}, }); - const firstRenderingOptions = rendering.lastCall.args[0]; + const firstRenderingOptions = rendering.mock.calls[0][0]; const { refine } = firstRenderingOptions; expect(helper.getQueryParameter('hitsPerPage')).toBe(11); refine(3); @@ -140,17 +196,17 @@ describe('connectHitsPerPage', () => { createURL: () => '#', }); - const secondRenderingOptions = rendering.lastCall.args[0]; + const secondRenderingOptions = rendering.mock.calls[1][0]; const { refine: renderSetValue } = secondRenderingOptions; expect(helper.getQueryParameter('hitsPerPage')).toBe(3); renderSetValue(10); expect(helper.getQueryParameter('hitsPerPage')).toBe(10); - expect(helper.search.callCount).toBe(2); + expect(helper.search).toHaveBeenCalledTimes(2); }); it('provides the current hitsPerPage value', () => { - const rendering = sinon.stub(); + const rendering = jest.fn(); const makeWidget = connectHitsPerPage(rendering); const widget = makeWidget({ items: [ @@ -163,7 +219,7 @@ describe('connectHitsPerPage', () => { const helper = jsHelper({}, '', { hitsPerPage: 7, }); - helper.search = sinon.stub(); + helper.search = jest.fn(); widget.init({ helper, @@ -172,7 +228,7 @@ describe('connectHitsPerPage', () => { onHistoryChange: () => {}, }); - const firstRenderingOptions = rendering.lastCall.args[0]; + const firstRenderingOptions = rendering.mock.calls[0][0]; expect(firstRenderingOptions.items).toMatchSnapshot(); firstRenderingOptions.refine(3); @@ -183,12 +239,12 @@ describe('connectHitsPerPage', () => { createURL: () => '#', }); - const secondRenderingOptions = rendering.lastCall.args[0]; + const secondRenderingOptions = rendering.mock.calls[1][0]; expect(secondRenderingOptions.items).toMatchSnapshot(); }); it('adds an option for the unselecting values, when the current hitsPerPage is defined elsewhere', () => { - const rendering = sinon.stub(); + const rendering = jest.fn(); const makeWidget = connectHitsPerPage(rendering); const widget = makeWidget({ items: [ @@ -200,7 +256,7 @@ describe('connectHitsPerPage', () => { const helper = jsHelper({}, '', { hitsPerPage: 7, }); - helper.search = sinon.stub(); + helper.search = jest.fn(); widget.init({ helper, @@ -209,7 +265,7 @@ describe('connectHitsPerPage', () => { onHistoryChange: () => {}, }); - const firstRenderingOptions = rendering.lastCall.args[0]; + const firstRenderingOptions = rendering.mock.calls[0][0]; expect(firstRenderingOptions.items).toHaveLength(3); firstRenderingOptions.refine(firstRenderingOptions.items[0].value); expect(helper.getQueryParameter('hitsPerPage')).not.toBeDefined(); @@ -224,14 +280,14 @@ describe('connectHitsPerPage', () => { createURL: () => '#', }); - const secondRenderingOptions = rendering.lastCall.args[0]; + const secondRenderingOptions = rendering.mock.calls[1][0]; expect(secondRenderingOptions.items).toHaveLength(3); secondRenderingOptions.refine(secondRenderingOptions.items[0].value); expect(helper.getQueryParameter('hitsPerPage')).not.toBeDefined(); }); it('the option for unselecting values should work even if stringified', () => { - const rendering = sinon.stub(); + const rendering = jest.fn(); const makeWidget = connectHitsPerPage(rendering); const widget = makeWidget({ items: [ @@ -243,7 +299,7 @@ describe('connectHitsPerPage', () => { const helper = jsHelper({}, '', { hitsPerPage: 7, }); - helper.search = sinon.stub(); + helper.search = jest.fn(); widget.init({ helper, @@ -252,7 +308,7 @@ describe('connectHitsPerPage', () => { onHistoryChange: () => {}, }); - const firstRenderingOptions = rendering.lastCall.args[0]; + const firstRenderingOptions = rendering.mock.calls[0][0]; expect(firstRenderingOptions.items).toHaveLength(3); firstRenderingOptions.refine(`${firstRenderingOptions.items[0].value}`); expect(helper.getQueryParameter('hitsPerPage')).not.toBeDefined(); @@ -267,14 +323,14 @@ describe('connectHitsPerPage', () => { createURL: () => '#', }); - const secondRenderingOptions = rendering.lastCall.args[0]; + const secondRenderingOptions = rendering.mock.calls[1][0]; expect(secondRenderingOptions.items).toHaveLength(3); secondRenderingOptions.refine(`${secondRenderingOptions.items[0].value}`); expect(helper.getQueryParameter('hitsPerPage')).not.toBeDefined(); }); it('Should be able to unselect using an empty string', () => { - const rendering = sinon.stub(); + const rendering = jest.fn(); const makeWidget = connectHitsPerPage(rendering); const widget = makeWidget({ items: [ @@ -286,7 +342,7 @@ describe('connectHitsPerPage', () => { const helper = jsHelper({}, '', { hitsPerPage: 7, }); - helper.search = sinon.stub(); + helper.search = jest.fn(); widget.init({ helper, @@ -295,7 +351,7 @@ describe('connectHitsPerPage', () => { onHistoryChange: () => {}, }); - const firstRenderingOptions = rendering.lastCall.args[0]; + const firstRenderingOptions = rendering.mock.calls[0][0]; expect(firstRenderingOptions.items).toHaveLength(3); firstRenderingOptions.refine(''); expect(helper.getQueryParameter('hitsPerPage')).not.toBeDefined(); @@ -310,7 +366,7 @@ describe('connectHitsPerPage', () => { createURL: () => '#', }); - const secondRenderingOptions = rendering.lastCall.args[0]; + const secondRenderingOptions = rendering.mock.calls[1][0]; expect(secondRenderingOptions.items).toHaveLength(3); secondRenderingOptions.refine(''); expect(helper.getQueryParameter('hitsPerPage')).not.toBeDefined(); @@ -328,7 +384,7 @@ describe('connectHitsPerPage', () => { }); const helper = jsHelper({}, '', widget.getConfiguration({})); - helper.search = sinon.stub(); + helper.search = jest.fn(); widget.init({ helper, diff --git a/src/connectors/hits-per-page/connectHitsPerPage.js b/src/connectors/hits-per-page/connectHitsPerPage.js index d46b2f2912..7894d4930a 100644 --- a/src/connectors/hits-per-page/connectHitsPerPage.js +++ b/src/connectors/hits-per-page/connectHitsPerPage.js @@ -20,6 +20,7 @@ search.addWidget( {value: 10, label: '10 results per page'}, {value: 42, label: '42 results per page'}, ], + [ transformItems ] }) ); Full documentation available at https://community.algolia.com/instantsearch.js/v2/connectors/connectHitsPerPage.html @@ -50,6 +51,7 @@ Full documentation available at https://community.algolia.com/instantsearch.js/v /** * @typedef {Object} HitsPerPageWidgetOptions * @property {HitsPerPageWidgetOptionsItem[]} items Array of objects defining the different values and labels. + * @property {function(object[]):object[]} [transformItems] Function to transform the items passed to the templates. */ /** @@ -111,7 +113,7 @@ export default function connectHitsPerPage(renderFn, unmountFn) { checkRendering(renderFn, usage); return (widgetParams = {}) => { - const { items: userItems } = widgetParams; + const { items: userItems, transformItems = items => items } = widgetParams; let items = userItems; if (!items) { @@ -167,7 +169,7 @@ The first one will be picked, you should probably set only one default value` renderFn( { - items: this._transformItems(state), + items: transformItems(this._normalizeItems(state)), refine: this.setHitsPerPage, hasNoResults: true, widgetParams, @@ -182,7 +184,7 @@ The first one will be picked, you should probably set only one default value` renderFn( { - items: this._transformItems(state), + items: transformItems(this._normalizeItems(state)), refine: this.setHitsPerPage, hasNoResults, widgetParams, @@ -192,7 +194,7 @@ The first one will be picked, you should probably set only one default value` ); }, - _transformItems({ hitsPerPage }) { + _normalizeItems({ hitsPerPage }) { return items.map(item => ({ ...item, isRefined: Number(item.value) === Number(hitsPerPage), diff --git a/src/connectors/hits/__tests__/connectHits-test.js b/src/connectors/hits/__tests__/connectHits-test.js index a574696ae0..23f9f588cb 100644 --- a/src/connectors/hits/__tests__/connectHits-test.js +++ b/src/connectors/hits/__tests__/connectHits-test.js @@ -1,5 +1,3 @@ -import sinon from 'sinon'; - import jsHelper from 'algoliasearch-helper'; const SearchResults = jsHelper.SearchResults; @@ -9,7 +7,7 @@ describe('connectHits', () => { it('Renders during init and render', () => { // test that the dummyRendering is called with the isFirstRendering // flag set accordingly - const rendering = sinon.stub(); + const rendering = jest.fn(); const makeWidget = connectHits(rendering); const widget = makeWidget({ escapeHits: true }); @@ -19,10 +17,10 @@ describe('connectHits', () => { }); // test if widget is not rendered yet at this point - expect(rendering.callCount).toBe(0); + expect(rendering).toHaveBeenCalledTimes(0); const helper = jsHelper({}, '', {}); - helper.search = sinon.stub(); + helper.search = jest.fn(); widget.init({ helper, @@ -31,13 +29,12 @@ describe('connectHits', () => { onHistoryChange: () => {}, }); + expect(rendering).toHaveBeenCalledTimes(1); // test that rendering has been called during init with isFirstRendering = true - expect(rendering.callCount).toBe(1); - // test if isFirstRendering is true during init - expect(rendering.lastCall.args[1]).toBe(true); - expect(rendering.lastCall.args[0].widgetParams).toEqual({ - escapeHits: true, - }); + expect(rendering).toHaveBeenLastCalledWith( + expect.objectContaining({ widgetParams: { escapeHits: true } }), + true + ); widget.render({ results: new SearchResults(helper.state, [{}]), @@ -46,21 +43,21 @@ describe('connectHits', () => { createURL: () => '#', }); + expect(rendering).toHaveBeenCalledTimes(2); // test that rendering has been called during init with isFirstRendering = false - expect(rendering.callCount).toBe(2); - expect(rendering.lastCall.args[1]).toBe(false); - expect(rendering.lastCall.args[0].widgetParams).toEqual({ - escapeHits: true, - }); + expect(rendering).toHaveBeenLastCalledWith( + expect.objectContaining({ widgetParams: { escapeHits: true } }), + false + ); }); it('Provides the hits and the whole results', () => { - const rendering = sinon.stub(); + const rendering = jest.fn(); const makeWidget = connectHits(rendering); const widget = makeWidget({}); const helper = jsHelper({}, '', {}); - helper.search = sinon.stub(); + helper.search = jest.fn(); widget.init({ helper, @@ -69,9 +66,13 @@ describe('connectHits', () => { onHistoryChange: () => {}, }); - const firstRenderingOptions = rendering.lastCall.args[0]; - expect(firstRenderingOptions.hits).toEqual([]); - expect(firstRenderingOptions.results).toBe(undefined); + expect(rendering).toHaveBeenLastCalledWith( + expect.objectContaining({ + hits: [], + results: undefined, + }), + expect.anything() + ); const hits = [{ fake: 'data' }, { sample: 'infos' }]; @@ -85,18 +86,22 @@ describe('connectHits', () => { createURL: () => '#', }); - const secondRenderingOptions = rendering.lastCall.args[0]; - expect(secondRenderingOptions.hits).toEqual(hits); - expect(secondRenderingOptions.results).toEqual(results); + expect(rendering).toHaveBeenLastCalledWith( + expect.objectContaining({ + hits, + results, + }), + expect.anything() + ); }); it('escape highlight properties if requested', () => { - const rendering = sinon.stub(); + const rendering = jest.fn(); const makeWidget = connectHits(rendering); const widget = makeWidget({ escapeHits: true }); const helper = jsHelper({}, '', {}); - helper.search = sinon.stub(); + helper.search = jest.fn(); widget.init({ helper, @@ -105,9 +110,13 @@ describe('connectHits', () => { onHistoryChange: () => {}, }); - const firstRenderingOptions = rendering.lastCall.args[0]; - expect(firstRenderingOptions.hits).toEqual([]); - expect(firstRenderingOptions.results).toBe(undefined); + expect(rendering).toHaveBeenLastCalledWith( + expect.objectContaining({ + hits: [], + results: undefined, + }), + expect.anything() + ); const hits = [ { @@ -139,8 +148,55 @@ describe('connectHits', () => { escapedHits.__escaped = true; - const secondRenderingOptions = rendering.lastCall.args[0]; - expect(secondRenderingOptions.hits).toEqual(escapedHits); - expect(secondRenderingOptions.results).toEqual(results); + expect(rendering).toHaveBeenLastCalledWith( + expect.objectContaining({ + hits: escapedHits, + results, + }), + expect.anything() + ); + }); + + it('transform items if requested', () => { + const rendering = jest.fn(); + const makeWidget = connectHits(rendering); + const widget = makeWidget({ + transformItems: items => items.map(() => ({ name: 'transformed' })), + }); + + const helper = jsHelper({}, '', {}); + helper.search = jest.fn(); + + widget.init({ + helper, + state: helper.state, + createURL: () => '#', + onHistoryChange: () => {}, + }); + + expect(rendering).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ hits: [], results: undefined }), + expect.anything() + ); + + const hits = [{ name: 'name 1' }, { name: 'name 2' }]; + + const results = new SearchResults(helper.state, [{ hits }]); + widget.render({ + results, + state: helper.state, + helper, + createURL: () => '#', + }); + + expect(rendering).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + hits: [{ name: 'transformed' }, { name: 'transformed' }], + results, + }), + expect.anything() + ); }); }); diff --git a/src/connectors/hits/connectHits.js b/src/connectors/hits/connectHits.js index 02d4fc9b31..f81f08b0ab 100644 --- a/src/connectors/hits/connectHits.js +++ b/src/connectors/hits/connectHits.js @@ -12,7 +12,8 @@ var customHits = connectHits(function render(params, isFirstRendering) { }); search.addWidget( customHits({ - [ escapeHits = false ] + [ escapeHits = false ], + [ transformItems ] }) ); Full documentation available at https://community.algolia.com/instantsearch.js/v2/connectors/connectHits.html @@ -28,6 +29,7 @@ Full documentation available at https://community.algolia.com/instantsearch.js/v /** * @typedef {Object} CustomHitsWidgetOptions * @property {boolean} [escapeHits = false] If true, escape HTML tags from `hits[i]._highlightResult`. + * @property {function(object[]):object[]} [transformItems] Function to transform the items passed to the templates. */ /** @@ -59,41 +61,51 @@ Full documentation available at https://community.algolia.com/instantsearch.js/v export default function connectHits(renderFn, unmountFn) { checkRendering(renderFn, usage); - return (widgetParams = {}) => ({ - getConfiguration() { - return widgetParams.escapeHits ? tagConfig : undefined; - }, + return (widgetParams = {}) => { + const { transformItems = items => items } = widgetParams; - init({ instantSearchInstance }) { - renderFn( - { - hits: [], - results: undefined, - instantSearchInstance, - widgetParams, - }, - true - ); - }, + return { + getConfiguration() { + return widgetParams.escapeHits ? tagConfig : undefined; + }, - render({ results, instantSearchInstance }) { - if (widgetParams.escapeHits && results.hits && results.hits.length > 0) { - results.hits = escapeHits(results.hits); - } + init({ instantSearchInstance }) { + renderFn( + { + hits: [], + results: undefined, + instantSearchInstance, + widgetParams, + }, + true + ); + }, - renderFn( - { - hits: results.hits, - results, - instantSearchInstance, - widgetParams, - }, - false - ); - }, + render({ results, instantSearchInstance }) { + results.hits = transformItems(results.hits); - dispose() { - unmountFn(); - }, - }); + if ( + widgetParams.escapeHits && + results.hits && + results.hits.length > 0 + ) { + results.hits = escapeHits(results.hits); + } + + renderFn( + { + hits: results.hits, + results, + instantSearchInstance, + widgetParams, + }, + false + ); + }, + + dispose() { + unmountFn(); + }, + }; + }; } diff --git a/src/connectors/infinite-hits/__tests__/connectInfiniteHits-test.js b/src/connectors/infinite-hits/__tests__/connectInfiniteHits-test.js index e75df1cadb..3c88cd8512 100644 --- a/src/connectors/infinite-hits/__tests__/connectInfiniteHits-test.js +++ b/src/connectors/infinite-hits/__tests__/connectInfiniteHits-test.js @@ -202,6 +202,58 @@ describe('connectInfiniteHits', () => { expect(secondRenderingOptions.results).toEqual(results); }); + it('transform items if requested', () => { + const rendering = jest.fn(); + const makeWidget = connectInfiniteHits(rendering); + const widget = makeWidget({ + transformItems: items => items.map(() => ({ name: 'transformed' })), + }); + + const helper = jsHelper({}, '', {}); + helper.search = jest.fn(); + + widget.init({ + helper, + state: helper.state, + createURL: () => '#', + onHistoryChange: () => {}, + }); + + const firstRenderingOptions = rendering.mock.calls[0][0]; + expect(firstRenderingOptions.hits).toEqual([]); + expect(firstRenderingOptions.results).toBe(undefined); + + const hits = [ + { + name: 'name 1', + }, + { + name: 'name 2', + }, + ]; + + const results = new SearchResults(helper.state, [{ hits }]); + widget.render({ + results, + state: helper.state, + helper, + createURL: () => '#', + }); + + const transformedHits = [ + { + name: 'transformed', + }, + { + name: 'transformed', + }, + ]; + + const secondRenderingOptions = rendering.mock.calls[1][0]; + expect(secondRenderingOptions.hits).toEqual(transformedHits); + expect(secondRenderingOptions.results).toEqual(results); + }); + it('does not render the same page twice', () => { const rendering = jest.fn(); const makeWidget = connectInfiniteHits(rendering); diff --git a/src/connectors/infinite-hits/connectInfiniteHits.js b/src/connectors/infinite-hits/connectInfiniteHits.js index 47e15fed82..1bf7a08e3e 100644 --- a/src/connectors/infinite-hits/connectInfiniteHits.js +++ b/src/connectors/infinite-hits/connectInfiniteHits.js @@ -14,7 +14,8 @@ var customInfiniteHits = connectInfiniteHits(function render(params, isFirstRend }); search.addWidget( customInfiniteHits({ - escapeHits: true, + [ escapeHits: true ], + [ transformItems ] }) ); Full documentation available at https://community.algolia.com/instantsearch.js/v2/connectors/connectInfiniteHits.html @@ -32,6 +33,7 @@ Full documentation available at https://community.algolia.com/instantsearch.js/v /** * @typedef {Object} CustomInfiniteHitsWidgetOptions * @property {boolean} [escapeHits = false] If true, escape HTML tags from `hits[i]._highlightResult`. + * @property {function(object[]):object[]} [transformItems] Function to transform the items passed to the templates. */ /** @@ -78,6 +80,7 @@ export default function connectInfiniteHits(renderFn, unmountFn) { checkRendering(renderFn, usage); return (widgetParams = {}) => { + const { transformItems = items => items } = widgetParams; let hitsCache = []; let lastReceivedPage = -1; @@ -110,6 +113,8 @@ export default function connectInfiniteHits(renderFn, unmountFn) { lastReceivedPage = -1; } + results.hits = transformItems(results.hits); + if ( widgetParams.escapeHits && results.hits && diff --git a/src/connectors/menu/__tests__/connectMenu-test.js b/src/connectors/menu/__tests__/connectMenu-test.js index 2fa6451cf0..14a6d55224 100644 --- a/src/connectors/menu/__tests__/connectMenu-test.js +++ b/src/connectors/menu/__tests__/connectMenu-test.js @@ -1,5 +1,3 @@ -import sinon from 'sinon'; - import jsHelper, { SearchResults, SearchParameters, @@ -11,7 +9,7 @@ describe('connectMenu', () => { let rendering; let makeWidget; beforeEach(() => { - rendering = sinon.stub(); + rendering = jest.fn(); makeWidget = connectMenu(rendering); }); @@ -82,10 +80,10 @@ describe('connectMenu', () => { }); // test if widget is not rendered yet at this point - expect(rendering.callCount).toBe(0); + expect(rendering).toHaveBeenCalledTimes(0); const helper = jsHelper({}, '', config); - helper.search = sinon.stub(); + helper.search = jest.fn(); widget.init({ helper, @@ -95,16 +93,18 @@ describe('connectMenu', () => { }); // test that rendering has been called during init with isFirstRendering = true - expect(rendering.callCount).toBe(1); + expect(rendering).toHaveBeenCalledTimes(1); // test if isFirstRendering is true during init - expect(rendering.lastCall.args[1]).toBe(true); - - const firstRenderingOptions = rendering.lastCall.args[0]; - expect(firstRenderingOptions.canRefine).toBe(false); - expect(firstRenderingOptions.widgetParams).toEqual({ - attributeName: 'myFacet', - limit: 9, - }); + expect(rendering).toHaveBeenLastCalledWith( + expect.objectContaining({ + canRefine: false, + widgetParams: { + attributeName: 'myFacet', + limit: 9, + }, + }), + true + ); widget.render({ results: new SearchResults(helper.state, [{}]), @@ -114,15 +114,17 @@ describe('connectMenu', () => { }); // test that rendering has been called during init with isFirstRendering = false - expect(rendering.callCount).toBe(2); - expect(rendering.lastCall.args[1]).toBe(false); - - const secondRenderingOptions = rendering.lastCall.args[0]; - expect(secondRenderingOptions.canRefine).toBe(false); - expect(secondRenderingOptions.widgetParams).toEqual({ - attributeName: 'myFacet', - limit: 9, - }); + expect(rendering).toHaveBeenCalledTimes(2); + expect(rendering).toHaveBeenLastCalledWith( + expect.objectContaining({ + canRefine: false, + widgetParams: { + attributeName: 'myFacet', + limit: 9, + }, + }), + false + ); }); it('Provide a function to clear the refinements at each step', () => { @@ -131,7 +133,7 @@ describe('connectMenu', () => { }); const helper = jsHelper({}, '', widget.getConfiguration({})); - helper.search = sinon.stub(); + helper.search = jest.fn(); helper.toggleRefinement('category', 'value'); @@ -142,7 +144,7 @@ describe('connectMenu', () => { onHistoryChange: () => {}, }); - const firstRenderingOptions = rendering.lastCall.args[0]; + const firstRenderingOptions = rendering.mock.calls[0][0]; const { refine } = firstRenderingOptions; refine('value'); expect(helper.hasRefinements('category')).toBe(false); @@ -156,7 +158,7 @@ describe('connectMenu', () => { createURL: () => '#', }); - const secondRenderingOptions = rendering.lastCall.args[0]; + const secondRenderingOptions = rendering.mock.calls[1][0]; const { refine: renderRefine } = secondRenderingOptions; renderRefine('value'); expect(helper.hasRefinements('category')).toBe(false); @@ -170,7 +172,7 @@ describe('connectMenu', () => { }); const helper = jsHelper({}, '', widget.getConfiguration({})); - helper.search = sinon.stub(); + helper.search = jest.fn(); helper.toggleRefinement('category', 'Decoration'); @@ -181,11 +183,13 @@ describe('connectMenu', () => { onHistoryChange: () => {}, }); - const firstRenderingOptions = rendering.lastCall.args[0]; // During the first rendering there are no facet values // The function get an empty array so that it doesn't break // over null-ish values. - expect(firstRenderingOptions.items).toEqual([]); + expect(rendering).toHaveBeenLastCalledWith( + expect.objectContaining({ items: [] }), + expect.anything() + ); widget.render({ results: new SearchResults(helper.state, [ @@ -211,23 +215,88 @@ describe('connectMenu', () => { createURL: () => '#', }); - const secondRenderingOptions = rendering.lastCall.args[0]; - expect(secondRenderingOptions.items).toEqual([ - { - label: 'Decoration', - value: 'Decoration', - count: 880, - isRefined: true, - data: null, - }, - { - label: 'Outdoor', - value: 'Outdoor', - count: 47, - isRefined: false, - data: null, - }, - ]); + expect(rendering).toHaveBeenLastCalledWith( + expect.objectContaining({ + items: [ + { + label: 'Decoration', + value: 'Decoration', + count: 880, + isRefined: true, + data: null, + }, + { + label: 'Outdoor', + value: 'Outdoor', + count: 47, + isRefined: false, + data: null, + }, + ], + }), + expect.anything() + ); + }); + + it('provides the correct transformed facet values', () => { + const widget = makeWidget({ + attributeName: 'category', + transformItems: items => + items.map(item => ({ + ...item, + label: 'transformed', + })), + }); + + const helper = jsHelper({}, '', widget.getConfiguration({})); + helper.search = jest.fn(); + + helper.toggleRefinement('category', 'Decoration'); + + widget.init({ + helper, + state: helper.state, + }); + + expect(rendering).toHaveBeenLastCalledWith( + expect.objectContaining({ + items: [], + }), + expect.anything() + ); + + widget.render({ + results: new SearchResults(helper.state, [ + { + hits: [], + facets: { + category: { + Decoration: 880, + }, + }, + }, + { + facets: { + category: { + Decoration: 880, + Outdoor: 47, + }, + }, + }, + ]), + state: helper.state, + helper, + }); + + expect(rendering).toHaveBeenLastCalledWith( + expect.objectContaining({ + items: [ + expect.objectContaining({ label: 'transformed' }), + expect.objectContaining({ label: 'transformed' }), + ], + }), + expect.anything() + ); }); describe('showMore', () => { @@ -279,9 +348,12 @@ describe('connectMenu', () => { onHistoryChange: () => {}, }); - // Then - const firstRenderingOptions = rendering.lastCall.args[0]; - expect(firstRenderingOptions.isShowingMore).toBe(false); + expect(rendering).toHaveBeenLastCalledWith( + expect.objectContaining({ + isShowingMore: false, + }), + expect.anything() + ); }); it('should toggle `isShowingMore` when `toggleShowMore` is called', () => { @@ -331,7 +403,8 @@ describe('connectMenu', () => { }); // Then - const firstRenderingOptions = rendering.lastCall.args[0]; + const firstRenderingOptions = + rendering.mock.calls[rendering.mock.calls.length - 1][0]; expect(firstRenderingOptions.isShowingMore).toBe(false); expect(firstRenderingOptions.items).toHaveLength(1); expect(firstRenderingOptions.canToggleShowMore).toBe(true); @@ -340,7 +413,8 @@ describe('connectMenu', () => { firstRenderingOptions.toggleShowMore(); // Then - const secondRenderingOptions = rendering.lastCall.args[0]; + const secondRenderingOptions = + rendering.mock.calls[rendering.mock.calls.length - 1][0]; expect(secondRenderingOptions.isShowingMore).toBe(true); expect(secondRenderingOptions.items).toHaveLength(2); expect(firstRenderingOptions.canToggleShowMore).toBe(true); @@ -391,7 +465,8 @@ describe('connectMenu', () => { createURL: () => '#', }); - const firstRenderingOptions = rendering.lastCall.args[0]; + const firstRenderingOptions = + rendering.mock.calls[rendering.mock.calls.length - 1][0]; expect(firstRenderingOptions.items).toHaveLength(1); expect(firstRenderingOptions.canToggleShowMore).toBe(false); }); @@ -406,7 +481,7 @@ describe('connectMenu', () => { }); const helper = jsHelper({}, '', widget.getConfiguration({})); - helper.search = sinon.stub(); + helper.search = jest.fn(); widget.init({ helper, diff --git a/src/connectors/menu/connectMenu.js b/src/connectors/menu/connectMenu.js index 44e44b3127..d5deda0e39 100644 --- a/src/connectors/menu/connectMenu.js +++ b/src/connectors/menu/connectMenu.js @@ -19,6 +19,7 @@ search.addWidget( [ limit ], [ showMoreLimit ] [ sortBy = ['name:asc'] ] + [ transformItems ] }) ); Full documentation available at https://community.algolia.com/instantsearch.js/v2/connectors/connectMenu.html @@ -40,6 +41,7 @@ Full documentation available at https://community.algolia.com/instantsearch.js/v * @property {string[]|function} [sortBy = ['name:asc']] 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). + * @property {function(object[]):object[]} [transformItems] Function to transform the items passed to the templates. */ /** @@ -113,6 +115,7 @@ export default function connectMenu(renderFn, unmountFn) { limit = 10, sortBy = ['name:asc'], showMoreLimit, + transformItems = items => items, } = widgetParams; if (!attributeName || (!isNaN(showMoreLimit) && showMoreLimit < limit)) { @@ -200,13 +203,15 @@ export default function connectMenu(renderFn, unmountFn) { render({ results, instantSearchInstance }) { const facetItems = results.getFacetValues(attributeName, { sortBy }).data || []; - const items = facetItems - .slice(0, this.getLimit()) - .map(({ name: label, path: value, ...item }) => ({ - ...item, - label, - value, - })); + const items = transformItems( + facetItems + .slice(0, this.getLimit()) + .map(({ name: label, path: value, ...item }) => ({ + ...item, + label, + value, + })) + ); this.toggleShowMore = this.createToggleShowMore({ results, diff --git a/src/connectors/numeric-refinement-list/__tests__/connectNumericRefinementList-test.js b/src/connectors/numeric-refinement-list/__tests__/connectNumericRefinementList-test.js index 7cc456f3d3..69d0e10840 100644 --- a/src/connectors/numeric-refinement-list/__tests__/connectNumericRefinementList-test.js +++ b/src/connectors/numeric-refinement-list/__tests__/connectNumericRefinementList-test.js @@ -1,4 +1,3 @@ -import sinon from 'sinon'; import jsHelper, { SearchResults, SearchParameters, @@ -18,7 +17,7 @@ describe('connectNumericRefinementList', () => { it('Renders during init and render', () => { // test that the dummyRendering is called with the isFirstRendering // flag set accordingly - const rendering = sinon.stub(); + const rendering = jest.fn(); const makeWidget = connectNumericRefinementList(rendering); const widget = makeWidget({ attributeName: 'numerics', @@ -32,10 +31,10 @@ describe('connectNumericRefinementList', () => { expect(widget.getConfiguration).toBe(undefined); // test if widget is not rendered yet at this point - expect(rendering.callCount).toBe(0); + expect(rendering).toHaveBeenCalledTimes(0); const helper = jsHelper({}); - helper.search = sinon.stub(); + helper.search = jest.fn(); widget.init({ helper, @@ -45,17 +44,21 @@ describe('connectNumericRefinementList', () => { }); // test that rendering has been called during init with isFirstRendering = true - expect(rendering.callCount).toBe(1); + expect(rendering).toHaveBeenCalledTimes(1); // test if isFirstRendering is true during init - expect(rendering.lastCall.args[1]).toBe(true); - expect(rendering.lastCall.args[0].widgetParams).toEqual({ - attributeName: 'numerics', - options: [ - { name: 'below 10', end: 10 }, - { name: '10 - 20', start: 10, end: 20 }, - { name: 'more than 20', start: 20 }, - ], - }); + expect(rendering).toHaveBeenLastCalledWith( + expect.objectContaining({ + widgetParams: { + attributeName: 'numerics', + options: [ + { name: 'below 10', end: 10 }, + { name: '10 - 20', start: 10, end: 20 }, + { name: 'more than 20', start: 20 }, + ], + }, + }), + true + ); widget.render({ results: new SearchResults(helper.state, [{ nbHits: 0 }]), @@ -65,20 +68,69 @@ describe('connectNumericRefinementList', () => { }); // test that rendering has been called during init with isFirstRendering = false - expect(rendering.callCount).toBe(2); - expect(rendering.lastCall.args[1]).toBe(false); - expect(rendering.lastCall.args[0].widgetParams).toEqual({ + expect(rendering).toHaveBeenCalledTimes(2); + expect(rendering).toHaveBeenLastCalledWith( + expect.objectContaining({ + widgetParams: { + attributeName: 'numerics', + options: [ + { name: 'below 10', end: 10 }, + { name: '10 - 20', start: 10, end: 20 }, + { name: 'more than 20', start: 20 }, + ], + }, + }), + false + ); + }); + + it('Renders during init and render with transformed items', () => { + const rendering = jest.fn(); + const makeWidget = connectNumericRefinementList(rendering); + const widget = makeWidget({ attributeName: 'numerics', - options: [ - { name: 'below 10', end: 10 }, - { name: '10 - 20', start: 10, end: 20 }, - { name: 'more than 20', start: 20 }, - ], + options: [{ name: 'below 10', end: 10 }], + transformItems: items => + items.map(item => ({ + ...item, + label: 'transformed', + })), }); + + const helper = jsHelper({}); + helper.search = jest.fn(); + + widget.init({ + helper, + state: helper.state, + createURL: () => '#', + onHistoryChange: () => {}, + }); + + expect(rendering).toHaveBeenLastCalledWith( + expect.objectContaining({ + items: [expect.objectContaining({ label: 'transformed' })], + }), + expect.anything() + ); + + widget.render({ + results: new SearchResults(helper.state, [{ nbHits: 0 }]), + state: helper.state, + helper, + createURL: () => '#', + }); + + expect(rendering).toHaveBeenLastCalledWith( + expect.objectContaining({ + items: [expect.objectContaining({ label: 'transformed' })], + }), + expect.anything() + ); }); it('Provide a function to update the refinements at each step', () => { - const rendering = sinon.stub(); + const rendering = jest.fn(); const makeWidget = connectNumericRefinementList(rendering); const widget = makeWidget({ attributeName: 'numerics', @@ -92,7 +144,7 @@ describe('connectNumericRefinementList', () => { }); const helper = jsHelper({}); - helper.search = sinon.stub(); + helper.search = jest.fn(); widget.init({ helper, @@ -101,7 +153,7 @@ describe('connectNumericRefinementList', () => { onHistoryChange: () => {}, }); - const firstRenderingOptions = rendering.lastCall.args[0]; + const firstRenderingOptions = rendering.mock.calls[0][0]; const { refine, items } = firstRenderingOptions; expect(helper.state.getNumericRefinements('numerics')).toEqual({}); refine(items[0].value); @@ -131,7 +183,7 @@ describe('connectNumericRefinementList', () => { createURL: () => '#', }); - const secondRenderingOptions = rendering.lastCall.args[0]; + const secondRenderingOptions = rendering.mock.calls[1][0]; const { refine: renderToggleRefinement, items: renderFacetValues, @@ -159,7 +211,7 @@ describe('connectNumericRefinementList', () => { }); it('provides the correct facet values', () => { - const rendering = sinon.stub(); + const rendering = jest.fn(); const makeWidget = connectNumericRefinementList(rendering); const widget = makeWidget({ attributeName: 'numerics', @@ -171,7 +223,7 @@ describe('connectNumericRefinementList', () => { }); const helper = jsHelper({}); - helper.search = sinon.stub(); + helper.search = jest.fn(); widget.init({ helper, @@ -180,16 +232,20 @@ describe('connectNumericRefinementList', () => { onHistoryChange: () => {}, }); - const firstRenderingOptions = rendering.lastCall.args[0]; - expect(firstRenderingOptions.items).toEqual([ - { - label: 'below 10', - value: encodeValue(undefined, 10), - isRefined: false, - }, - { label: '10 - 20', value: encodeValue(10, 20), isRefined: false }, - { label: 'more than 20', value: encodeValue(20), isRefined: false }, - ]); + expect(rendering).toHaveBeenLastCalledWith( + expect.objectContaining({ + items: [ + { + label: 'below 10', + value: encodeValue(undefined, 10), + isRefined: false, + }, + { label: '10 - 20', value: encodeValue(10, 20), isRefined: false }, + { label: 'more than 20', value: encodeValue(20), isRefined: false }, + ], + }), + expect.anything() + ); widget.render({ results: new SearchResults(helper.state, [{}]), @@ -198,20 +254,24 @@ describe('connectNumericRefinementList', () => { createURL: () => '#', }); - const secondRenderingOptions = rendering.lastCall.args[0]; - expect(secondRenderingOptions.items).toEqual([ - { - label: 'below 10', - value: encodeValue(undefined, 10), - isRefined: false, - }, - { label: '10 - 20', value: encodeValue(10, 20), isRefined: false }, - { label: 'more than 20', value: encodeValue(20), isRefined: false }, - ]); + expect(rendering).toHaveBeenLastCalledWith( + expect.objectContaining({ + items: [ + { + label: 'below 10', + value: encodeValue(undefined, 10), + isRefined: false, + }, + { label: '10 - 20', value: encodeValue(10, 20), isRefined: false }, + { label: 'more than 20', value: encodeValue(20), isRefined: false }, + ], + }), + expect.anything() + ); }); it('provides isRefined for the currently selected value', () => { - const rendering = sinon.stub(); + const rendering = jest.fn(); const makeWidget = connectNumericRefinementList(rendering); const listOptions = [ { name: 'below 10', end: 10 }, @@ -226,7 +286,7 @@ describe('connectNumericRefinementList', () => { }); const helper = jsHelper({}); - helper.search = sinon.stub(); + helper.search = jest.fn(); widget.init({ helper, @@ -235,7 +295,8 @@ describe('connectNumericRefinementList', () => { onHistoryChange: () => {}, }); - let refine = rendering.lastCall.args[0].refine; + let refine = + rendering.mock.calls[rendering.mock.calls.length - 1][0].refine; listOptions.forEach((option, i) => { refine(encodeValue(option.start, option.end)); @@ -254,7 +315,8 @@ describe('connectNumericRefinementList', () => { // Then we modify the isRefined value of the one that is supposed to be refined expectedResults[i].isRefined = true; - const renderingParameters = rendering.lastCall.args[0]; + const renderingParameters = + rendering.mock.calls[rendering.mock.calls.length - 1][0]; expect(renderingParameters.items).toEqual(expectedResults); refine = renderingParameters.refine; @@ -262,7 +324,7 @@ describe('connectNumericRefinementList', () => { }); it('when the state is cleared, the "no value" value should be refined', () => { - const rendering = sinon.stub(); + const rendering = jest.fn(); const makeWidget = connectNumericRefinementList(rendering); const listOptions = [ { name: 'below 10', end: 10 }, @@ -277,7 +339,7 @@ describe('connectNumericRefinementList', () => { }); const helper = jsHelper({}); - helper.search = sinon.stub(); + helper.search = jest.fn(); widget.init({ helper, @@ -286,7 +348,8 @@ describe('connectNumericRefinementList', () => { onHistoryChange: () => {}, }); - const refine = rendering.lastCall.args[0].refine; + const refine = + rendering.mock.calls[rendering.mock.calls.length - 1][0].refine; // a user selects a value in the refinement list refine(encodeValue(listOptions[0].start, listOptions[0].end)); @@ -301,7 +364,8 @@ describe('connectNumericRefinementList', () => { const expectedResults0 = [...listOptions].map(mapOptionsToItems); expectedResults0[0].isRefined = true; - const renderingParameters0 = rendering.lastCall.args[0]; + const renderingParameters0 = + rendering.mock.calls[rendering.mock.calls.length - 1][0]; expect(renderingParameters0.items).toEqual(expectedResults0); // All the refinements are cleared by a third party @@ -318,7 +382,8 @@ describe('connectNumericRefinementList', () => { const expectedResults1 = [...listOptions].map(mapOptionsToItems); expectedResults1[4].isRefined = true; - const renderingParameters1 = rendering.lastCall.args[0]; + const renderingParameters1 = + rendering.mock.calls[rendering.mock.calls.length - 1][0]; expect(renderingParameters1.items).toEqual(expectedResults1); }); @@ -382,7 +447,7 @@ describe('connectNumericRefinementList', () => { }); const helper = jsHelper({}); - helper.search = sinon.stub(); + helper.search = jest.fn(); helper.setPage(2); widget.init({ diff --git a/src/connectors/numeric-refinement-list/connectNumericRefinementList.js b/src/connectors/numeric-refinement-list/connectNumericRefinementList.js index 18b36be164..7536cc1989 100644 --- a/src/connectors/numeric-refinement-list/connectNumericRefinementList.js +++ b/src/connectors/numeric-refinement-list/connectNumericRefinementList.js @@ -17,6 +17,7 @@ search.addWidget( customNumericRefinementList({ attributeName, options, + transformItems, }) ); Full documentation available at https://community.algolia.com/instantsearch.js/v2/connectors/connectNumericRefinementList.html @@ -41,6 +42,7 @@ Full documentation available at https://community.algolia.com/instantsearch.js/v * @typedef {Object} CustomNumericRefinementListWidgetOptions * @property {string} attributeName Name of the attribute for filtering. * @property {NumericRefinementListOption[]} options List of all the options. + * @property {function(object[]):object[]} [transformItems] Function to transform the items passed to the templates. */ /** @@ -114,7 +116,11 @@ export default function connectNumericRefinementList(renderFn, unmountFn) { checkRendering(renderFn, usage); return (widgetParams = {}) => { - const { attributeName, options } = widgetParams; + const { + attributeName, + options, + transformItems = items => items, + } = widgetParams; if (!attributeName || !options) { throw new Error(usage); @@ -144,7 +150,7 @@ export default function connectNumericRefinementList(renderFn, unmountFn) { renderFn( { createURL: this._createURL(helper.state), - items: this._prepareItems(helper.state), + items: transformItems(this._prepareItems(helper.state)), hasNoResults: true, refine: this._refine, instantSearchInstance, @@ -158,7 +164,7 @@ export default function connectNumericRefinementList(renderFn, unmountFn) { renderFn( { createURL: this._createURL(state), - items: this._prepareItems(state), + items: transformItems(this._prepareItems(state)), hasNoResults: results.nbHits === 0, refine: this._refine, instantSearchInstance, diff --git a/src/connectors/numeric-selector/__tests__/connectNumericSelector-test.js b/src/connectors/numeric-selector/__tests__/connectNumericSelector-test.js index d43e215a6f..beb3e9dbde 100644 --- a/src/connectors/numeric-selector/__tests__/connectNumericSelector-test.js +++ b/src/connectors/numeric-selector/__tests__/connectNumericSelector-test.js @@ -74,6 +74,55 @@ describe('connectNumericSelector', () => { }); }); + it('Renders during init and render with transformed items', () => { + const rendering = jest.fn(); + const makeWidget = connectNumericSelector(rendering); + const listOptions = [ + { name: '10', value: 10 }, + { name: '20', value: 20 }, + { name: '30', value: 30 }, + ]; + const widget = makeWidget({ + attributeName: 'numerics', + options: listOptions, + transformItems: items => + items.map(item => ({ ...item, label: 'transformed' })), + }); + + const config = widget.getConfiguration({}, {}); + + const helper = jsHelper({}, '', config); + helper.search = jest.fn(); + + widget.init({ + helper, + state: helper.state, + createURL: () => '#', + onHistoryChange: () => {}, + }); + + const firstRenderingOptions = rendering.mock.calls[0][0]; + expect(firstRenderingOptions.options).toEqual([ + { name: '10', value: 10, label: 'transformed' }, + { name: '20', value: 20, label: 'transformed' }, + { name: '30', value: 30, label: 'transformed' }, + ]); + + widget.render({ + results: new SearchResults(helper.state, [{ nbHits: 0 }]), + state: helper.state, + helper, + createURL: () => '#', + }); + + const secondRenderingOptions = rendering.mock.calls[1][0]; + expect(secondRenderingOptions.options).toEqual([ + { name: '10', value: 10, label: 'transformed' }, + { name: '20', value: 20, label: 'transformed' }, + { name: '30', value: 30, label: 'transformed' }, + ]); + }); + it('Reads the default value from the URL if possible', () => { // test that the dummyRendering is called with the isFirstRendering // flag set accordingly diff --git a/src/connectors/numeric-selector/connectNumericSelector.js b/src/connectors/numeric-selector/connectNumericSelector.js index 33d94ad266..ab29a59a6e 100644 --- a/src/connectors/numeric-selector/connectNumericSelector.js +++ b/src/connectors/numeric-selector/connectNumericSelector.js @@ -15,7 +15,8 @@ search.addWidget( customNumericSelector({ attributeName, options, - [ operator = '=' ] + [ operator = '=' ], + [ transformItems ] }) ); Full documentation available at https://community.algolia.com/instantsearch.js/v2/connectors/connectNumericSelector.html @@ -33,6 +34,7 @@ Full documentation available at https://community.algolia.com/instantsearch.js/v * @property {string} attributeName Name of the attribute for faceting (eg. "free_shipping"). * @property {NumericSelectorOption[]} options Array of objects defining the different values and labels. * @property {string} [operator = '='] The operator to use to refine. Supports following operators: <, <=, =, >, >= and !=. + * @property {function(object[]):object[]} [transformItems] Function to transform the items passed to the templates. */ /** @@ -98,7 +100,12 @@ export default function connectNumericSelector(renderFn, unmountFn) { checkRendering(renderFn, usage); return (widgetParams = {}) => { - const { attributeName, options, operator = '=' } = widgetParams; + const { + attributeName, + options, + operator = '=', + transformItems = items => items, + } = widgetParams; if (!attributeName || !options) { throw new Error(usage); @@ -131,7 +138,7 @@ export default function connectNumericSelector(renderFn, unmountFn) { renderFn( { currentRefinement: this._getRefinedValue(helper.state), - options, + options: transformItems(options), refine: this._refine, hasNoResults: true, instantSearchInstance, @@ -145,7 +152,7 @@ export default function connectNumericSelector(renderFn, unmountFn) { renderFn( { currentRefinement: this._getRefinedValue(helper.state), - options, + options: transformItems(options), refine: this._refine, hasNoResults: results.nbHits === 0, instantSearchInstance, diff --git a/src/connectors/refinement-list/__tests__/connectRefinementList-test.js b/src/connectors/refinement-list/__tests__/connectRefinementList-test.js index 201ce1bc93..11bc496276 100644 --- a/src/connectors/refinement-list/__tests__/connectRefinementList-test.js +++ b/src/connectors/refinement-list/__tests__/connectRefinementList-test.js @@ -145,6 +145,69 @@ describe('connectRefinementList', () => { }); }); + it('transforms items if requested', () => { + const { makeWidget, rendering } = createWidgetFactory(); + const widget = makeWidget({ + attributeName: 'category', + transformItems: items => + items.map(item => ({ + ...item, + label: 'transformed', + value: 'transformed', + highlighted: 'transformed', + })), + }); + + const helper = jsHelper({}, '', widget.getConfiguration({})); + helper.search = jest.fn(); + + widget.init({ + helper, + state: helper.state, + }); + + const firstRenderingOptions = rendering.mock.calls[0][0]; + expect(firstRenderingOptions.items).toEqual([]); + + widget.render({ + results: new SearchResults(helper.state, [ + { + hits: [], + facets: { + category: { + c1: 880, + c2: 47, + }, + }, + }, + { + facets: { + category: { + c1: 880, + c2: 47, + }, + }, + }, + ]), + state: helper.state, + helper, + }); + + const secondRenderingOptions = rendering.mock.calls[1][0]; + expect(secondRenderingOptions.items).toEqual([ + expect.objectContaining({ + label: 'transformed', + value: 'transformed', + highlighted: 'transformed', + }), + expect.objectContaining({ + label: 'transformed', + value: 'transformed', + highlighted: 'transformed', + }), + ]); + }); + it('Provide a function to clear the refinements at each step', () => { const { makeWidget, rendering } = createWidgetFactory(); const widget = makeWidget({ @@ -737,6 +800,110 @@ describe('connectRefinementList', () => { }); }); + it('can search in facet values with transformed items', () => { + const { makeWidget, rendering } = createWidgetFactory(); + const widget = makeWidget({ + attributeName: 'category', + limit: 2, + transformItems: items => + items.map(item => ({ + ...item, + label: 'transformed', + value: 'transformed', + highlighted: 'transformed', + })), + }); + + const helper = jsHelper({}, '', widget.getConfiguration({})); + helper.search = jest.fn(); + helper.searchForFacetValues = jest.fn().mockReturnValue( + Promise.resolve({ + exhaustiveFacetsCount: true, + facetHits: [ + { + count: 33, + highlighted: 'will be transformed', + value: 'will be transformed', + }, + { + count: 9, + highlighted: 'will be transformed', + value: 'will be transformed', + }, + ], + processingTimeMS: 1, + }) + ); + + // Simulate the lifecycle + widget.init({ + helper, + state: helper.state, + createURL: () => '#', + onHistoryChange: () => {}, + }); + expect(rendering).toHaveBeenCalledTimes(1); + + widget.render({ + results: new SearchResults(helper.state, [ + { + hits: [], + facets: { + category: { + c1: 880, + }, + }, + }, + { + facets: { + category: { + c1: 880, + }, + }, + }, + ]), + state: helper.state, + helper, + createURL: () => '#', + }); + expect(rendering).toHaveBeenCalledTimes(2); + // Simulation end + + const search = rendering.mock.calls[1][0].searchForItems; + search('transfo'); + + const [ + sffvFacet, + sffvQuery, + maxNbItems, + paramOverride, + ] = helper.searchForFacetValues.mock.calls[0]; + + expect(sffvQuery).toBe('transfo'); + expect(sffvFacet).toBe('category'); + expect(maxNbItems).toBe(2); + expect(paramOverride).toEqual({ + highlightPreTag: undefined, + highlightPostTag: undefined, + }); + + return Promise.resolve().then(() => { + expect(rendering).toHaveBeenCalledTimes(3); + expect(rendering.mock.calls[2][0].items).toEqual([ + expect.objectContaining({ + highlighted: 'transformed', + label: 'transformed', + value: 'transformed', + }), + expect.objectContaining({ + highlighted: 'transformed', + label: 'transformed', + value: 'transformed', + }), + ]); + }); + }); + it('can search in facet values, and reset pre post tags if needed', () => { const { makeWidget, rendering } = createWidgetFactory(); const widget = makeWidget({ diff --git a/src/connectors/refinement-list/connectRefinementList.js b/src/connectors/refinement-list/connectRefinementList.js index 66bdc6dc12..6e16443157 100644 --- a/src/connectors/refinement-list/connectRefinementList.js +++ b/src/connectors/refinement-list/connectRefinementList.js @@ -25,7 +25,8 @@ search.addWidget( [ limit ], [ showMoreLimit ], [ sortBy = ['isRefined', 'count:desc', 'name:asc'] ], - [ escapeFacetValues = false ] + [ escapeFacetValues = false ], + [ transformItems ] }) ); @@ -69,6 +70,7 @@ export const checkUsage = ({ * is showing more items. * @property {string[]|function} [sortBy = ['isRefined', 'count:desc', 'name:asc']] How to sort refinements. Possible values: `count|isRefined|name:asc|name:desc`. * @property {boolean} [escapeFacetValues = false] Escapes the content of the facet values. + * @property {function(object[]):object[]} [transformItems] Function to transform the items passed to the templates. */ /** @@ -159,6 +161,7 @@ export default function connectRefinementList(renderFn, unmountFn) { showMoreLimit, sortBy = ['isRefined', 'count:desc', 'name:asc'], escapeFacetValues = false, + transformItems = items => items, } = widgetParams; checkUsage({ @@ -267,12 +270,12 @@ export default function connectRefinementList(renderFn, unmountFn) { ? escapeFacets(results.facetHits) : results.facetHits; - const normalizedFacetValues = facetValues.map( - ({ value, ...item }) => ({ + const normalizedFacetValues = transformItems( + facetValues.map(({ value, ...item }) => ({ ...item, value, label: value, - }) + })) ); render({ @@ -369,7 +372,9 @@ export default function connectRefinementList(renderFn, unmountFn) { } = renderOptions; const facetValues = results.getFacetValues(attributeName, { sortBy }); - const items = facetValues.slice(0, this.getLimit()).map(formatItems); + const items = transformItems( + facetValues.slice(0, this.getLimit()).map(formatItems) + ); const maxValuesPerFacetConfig = state.getQueryParameter( 'maxValuesPerFacet' diff --git a/src/connectors/sort-by-selector/__tests__/connectSortBySelector-test.js b/src/connectors/sort-by-selector/__tests__/connectSortBySelector-test.js index 4a8afaefd7..62ed795dfb 100644 --- a/src/connectors/sort-by-selector/__tests__/connectSortBySelector-test.js +++ b/src/connectors/sort-by-selector/__tests__/connectSortBySelector-test.js @@ -1,5 +1,3 @@ -import sinon from 'sinon'; - import jsHelper, { SearchResults, SearchParameters, @@ -12,13 +10,11 @@ describe('connectSortBySelector', () => { it('Renders during init and render', () => { // test that the dummyRendering is called with the isFirstRendering // flag set accordingly - const rendering = sinon.stub(); + const rendering = jest.fn(); const makeWidget = connectSortBySelector(rendering); const instantSearchInstance = instantSearch({ - apiKey: '', - appId: '', indexName: 'defaultIndex', - createAlgoliaClient: () => ({}), + searchClient: { search() {} }, }); const indices = [ @@ -30,7 +26,7 @@ describe('connectSortBySelector', () => { expect(widget.getConfiguration).toBe(undefined); const helper = jsHelper({}, indices[0].name); - helper.search = sinon.stub(); + helper.search = jest.fn(); widget.init({ helper, @@ -40,25 +36,19 @@ describe('connectSortBySelector', () => { instantSearchInstance, }); - { - // should call the rendering once with isFirstRendering to true - expect(rendering.callCount).toBe(1); - const isFirstRendering = rendering.lastCall.args[1]; - expect(isFirstRendering).toBe(true); - - // should provide good values for the first rendering - const { - currentRefinement, - options, - widgetParams, - } = rendering.lastCall.args[0]; - expect(currentRefinement).toBe(helper.state.index); - expect(widgetParams).toEqual({ indices }); - expect(options).toEqual([ - { label: 'Sort products by relevance', value: 'relevance' }, - { label: 'Sort products by price', value: 'priceASC' }, - ]); - } + // should call the rendering once with isFirstRendering to true + expect(rendering).toHaveBeenCalledTimes(1); + expect(rendering).toHaveBeenLastCalledWith( + expect.objectContaining({ + currentRefinement: helper.state.index, + widgetParams: { indices }, + options: [ + { label: 'Sort products by relevance', value: 'relevance' }, + { label: 'Sort products by price', value: 'priceASC' }, + ], + }), + true + ); widget.render({ results: new SearchResults(helper.state, [{}]), @@ -67,30 +57,82 @@ describe('connectSortBySelector', () => { createURL: () => '#', }); - { - // Should call the rendering a second time, with isFirstRendering to false - expect(rendering.callCount).toBe(2); - const isFirstRendering = rendering.lastCall.args[1]; - expect(isFirstRendering).toBe(false); + // Should call the rendering a second time, with isFirstRendering to false + expect(rendering).toHaveBeenCalledTimes(2); + expect(rendering).toHaveBeenLastCalledWith( + expect.objectContaining({ + currentRefinement: helper.state.index, + widgetParams: { indices }, + options: [ + { label: 'Sort products by relevance', value: 'relevance' }, + { label: 'Sort products by price', value: 'priceASC' }, + ], + }), + false + ); + }); - // should provide good values after the first search - const { currentRefinement, options } = rendering.lastCall.args[0]; - expect(currentRefinement).toBe(helper.state.index); - expect(options).toEqual([ - { label: 'Sort products by relevance', value: 'relevance' }, - { label: 'Sort products by price', value: 'priceASC' }, - ]); - } + it('Renders with transformed items', () => { + const rendering = jest.fn(); + const makeWidget = connectSortBySelector(rendering); + const instantSearchInstance = instantSearch({ + indexName: 'defaultIndex', + searchClient: { search() {} }, + }); + + const indices = [ + { label: 'Sort products by relevance', name: 'relevance' }, + { label: 'Sort products by price', name: 'priceASC' }, + ]; + const widget = makeWidget({ + indices, + transformItems: items => + items.map(item => ({ ...item, label: 'transformed' })), + }); + + const helper = jsHelper({}, indices[0].name); + helper.search = jest.fn(); + + widget.init({ + helper, + state: helper.state, + instantSearchInstance, + }); + + expect(rendering).toHaveBeenLastCalledWith( + expect.objectContaining({ + options: [ + { label: 'transformed', value: 'relevance' }, + { label: 'transformed', value: 'priceASC' }, + ], + }), + expect.anything() + ); + + widget.render({ + results: new SearchResults(helper.state, [{}]), + helper, + state: helper.state, + instantSearchInstance, + }); + + expect(rendering).toHaveBeenLastCalledWith( + expect.objectContaining({ + options: [ + { label: 'transformed', value: 'relevance' }, + { label: 'transformed', value: 'priceASC' }, + ], + }), + expect.anything() + ); }); it('Provides a function to update the index at each step', () => { - const rendering = sinon.stub(); + const rendering = jest.fn(); const makeWidget = connectSortBySelector(rendering); const instantSearchInstance = instantSearch({ - apiKey: '', - appId: '', indexName: 'defaultIndex', - createAlgoliaClient: () => ({}), + searchClient: { search() {} }, }); const indices = [ @@ -102,7 +144,7 @@ describe('connectSortBySelector', () => { }); const helper = jsHelper({}, indices[0].name); - helper.search = sinon.stub(); + helper.search = jest.fn(); widget.init({ helper, @@ -115,12 +157,13 @@ describe('connectSortBySelector', () => { { // first rendering expect(helper.state.index).toBe(indices[0].name); - const renderOptions = rendering.lastCall.args[0]; + const renderOptions = + rendering.mock.calls[rendering.mock.calls.length - 1][0]; const { refine, currentRefinement } = renderOptions; expect(currentRefinement).toBe(helper.state.index); refine('bip'); expect(helper.state.index).toBe('bip'); - expect(helper.search.callCount).toBe(1); + expect(helper.search).toHaveBeenCalledTimes(1); } widget.render({ @@ -133,12 +176,13 @@ describe('connectSortBySelector', () => { { // Second rendering expect(helper.state.index).toBe('bip'); - const renderOptions = rendering.lastCall.args[0]; + const renderOptions = + rendering.mock.calls[rendering.mock.calls.length - 1][0]; const { refine, currentRefinement } = renderOptions; expect(currentRefinement).toBe('bip'); refine('bop'); expect(helper.state.index).toBe('bop'); - expect(helper.search.callCount).toBe(2); + expect(helper.search).toHaveBeenCalledTimes(2); } }); @@ -147,10 +191,8 @@ describe('connectSortBySelector', () => { const rendering = jest.fn(); const makeWidget = connectSortBySelector(rendering); const instantSearchInstance = instantSearch({ - apiKey: '', - appId: '', indexName: 'relevance', - createAlgoliaClient: () => ({}), + searchClient: { search() {} }, }); const indices = [ { label: 'Sort products by relevance', name: 'relevance' }, diff --git a/src/connectors/sort-by-selector/connectSortBySelector.js b/src/connectors/sort-by-selector/connectSortBySelector.js index 24f00c2425..fd959abab3 100644 --- a/src/connectors/sort-by-selector/connectSortBySelector.js +++ b/src/connectors/sort-by-selector/connectSortBySelector.js @@ -13,7 +13,10 @@ var customSortBySelector = connectSortBySelector(function render(params, isFirst // } }); search.addWidget( - customSortBySelector({ indices }) + customSortBySelector({ + indices, + [ transformItems ] + }) ); Full documentation available at https://community.algolia.com/instantsearch.js/v2/connectors/connectSortBySelector.html `; @@ -27,6 +30,7 @@ Full documentation available at https://community.algolia.com/instantsearch.js/v /** * @typedef {Object} CustomSortBySelectorWidgetOptions * @property {SortBySelectorIndices[]} indices Array of objects defining the different indices to choose from. + * @property {function(object[]):object[]} [transformItems] Function to transform the items passed to the templates. */ /** @@ -98,7 +102,7 @@ export default function connectSortBySelector(renderFn, unmountFn) { checkRendering(renderFn, usage); return (widgetParams = {}) => { - const { indices } = widgetParams; + const { indices, transformItems = items => items } = widgetParams; if (!indices) { throw new Error(usage); @@ -129,7 +133,7 @@ export default function connectSortBySelector(renderFn, unmountFn) { renderFn( { currentRefinement: currentIndex, - options: selectorOptions, + options: transformItems(selectorOptions), refine: this.setIndex, hasNoResults: true, widgetParams, @@ -143,7 +147,7 @@ export default function connectSortBySelector(renderFn, unmountFn) { renderFn( { currentRefinement: helper.getIndex(), - options: selectorOptions, + options: transformItems(selectorOptions), refine: this.setIndex, hasNoResults: results.nbHits === 0, widgetParams, diff --git a/src/widgets/breadcrumb/__tests__/__snapshots__/breadcrumb-test.js.snap b/src/widgets/breadcrumb/__tests__/__snapshots__/breadcrumb-test.js.snap new file mode 100644 index 0000000000..cf3d1f4857 --- /dev/null +++ b/src/widgets/breadcrumb/__tests__/__snapshots__/breadcrumb-test.js.snap @@ -0,0 +1,49 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`breadcrumb() render renders transformed items correctly 1`] = ` + Digital Cameras", + }, + Object { + "name": "Digital Cameras", + "transformed": true, + "value": null, + }, + ] + } + refine={[Function]} + separator=" > " + shouldAutoHideContainer={false} + templateProps={ + Object { + "templates": Object { + "home": "Home", + "separator": "", + }, + "templatesConfig": undefined, + "transformData": undefined, + "useCustomCompileOptions": Object { + "home": false, + "separator": false, + }, + } + } +/> +`; diff --git a/src/widgets/breadcrumb/__tests__/breadcrumb-test.js b/src/widgets/breadcrumb/__tests__/breadcrumb-test.js new file mode 100644 index 0000000000..bd8175fb3a --- /dev/null +++ b/src/widgets/breadcrumb/__tests__/breadcrumb-test.js @@ -0,0 +1,97 @@ +import breadcrumb from '../breadcrumb'; + +describe('breadcrumb()', () => { + let container; + let attributes; + let ReactDOM; + + beforeEach(() => { + container = document.createElement('div'); + attributes = ['hierarchicalCategories.lvl0', 'hierarchicalCategories.lvl1']; + ReactDOM = { render: jest.fn() }; + breadcrumb.__Rewire__('render', ReactDOM.render); + }); + + describe('render', () => { + let results; + let helper; + let state; + + beforeEach(() => { + const data = [ + { + name: 'Cameras & Camcorders', + path: 'Cameras & Camcorders', + count: 1369, + isRefined: true, + data: [ + { + name: 'Digital Cameras', + path: 'Cameras & Camcorders > Digital Cameras', + count: 170, + isRefined: true, + data: null, + }, + ], + }, + ]; + + results = { + getFacetValues: jest.fn(() => ({ data })), + hierarchicalFacets: [ + { + name: 'hierarchicalCategories.lvl0', + count: null, + isRefined: true, + path: null, + data, + }, + ], + }; + + helper = { + search: jest.fn(), + toggleRefinement: jest.fn().mockReturnThis(), + }; + + state = { + toggleRefinement: jest.fn().mockReturnThis(), + hierarchicalFacets: [ + { + attributes: [ + 'hierarchicalCategories.lvl0', + 'hierarchicalCategories.lvl1', + ], + name: 'hierarchicalCategories.lvl0', + separator: ' > ', + rootPath: null, + }, + ], + }; + }); + + it('renders transformed items correctly', () => { + const widget = breadcrumb({ + container, + attributes, + transformItems: items => + items.map(item => ({ ...item, transformed: true })), + }); + widget.init({ + helper, + instantSearchInstance: {}, + }); + widget.render({ + results, + state, + instantSearchInstance: {}, + }); + + expect(ReactDOM.render.mock.calls[0][0]).toMatchSnapshot(); + }); + + afterEach(() => { + breadcrumb.__ResetDependency__('render'); + }); + }); +}); diff --git a/src/widgets/breadcrumb/breadcrumb.js b/src/widgets/breadcrumb/breadcrumb.js index f46f5b4c47..fd61b449f5 100644 --- a/src/widgets/breadcrumb/breadcrumb.js +++ b/src/widgets/breadcrumb/breadcrumb.js @@ -60,6 +60,7 @@ breadcrumb({ [ cssClasses.{disabledLabel, home, label, root, separator}={} ], [ templates.{home, separator}] [ transformData.{item} ], + [ transformItems ], })`; /** @@ -92,6 +93,7 @@ breadcrumb({ * @property {BreadcrumbTransforms} [transformData] Set of functions to transform the data passed to the templates. * @property {boolean} [autoHideContainer=true] Hides the container when there are no items in the breadcrumb. * @property {BreadcrumbCSSClasses} [cssClasses] CSS classes to add to the wrapping elements. + * @property {function(object[]):object[]} [transformItems] Function to transform the items passed to the templates. */ /** @@ -156,6 +158,7 @@ export default function breadcrumb({ separator = ' > ', templates = defaultTemplates, transformData, + transformItems, } = {}) { if (!container) { throw new Error(usage); @@ -186,7 +189,7 @@ export default function breadcrumb({ const makeBreadcrumb = connectBreadcrumb(specializedRenderer, () => unmountComponentAtNode(containerNode) ); - return makeBreadcrumb({ attributes, rootPath }); + return makeBreadcrumb({ attributes, rootPath, transformItems }); } catch (e) { throw new Error(usage); } diff --git a/src/widgets/current-refined-values/__tests__/__snapshots__/current-refined-values-test.js.snap b/src/widgets/current-refined-values/__tests__/__snapshots__/current-refined-values-test.js.snap index 9eef79fb7e..409fdc1bcc 100644 --- a/src/widgets/current-refined-values/__tests__/__snapshots__/current-refined-values-test.js.snap +++ b/src/widgets/current-refined-values/__tests__/__snapshots__/current-refined-values-test.js.snap @@ -3070,6 +3070,223 @@ exports[`currentRefinedValues() render() options.templates should pass it in tem /> `; +exports[`currentRefinedValues() render() options.transformItems should transform passed items 1`] = ` +=", + "transformed": true, + "type": "numeric", + }, + Object { + "attributeName": "numericFacet", + "computedLabel": "≤ 2", + "name": "2", + "numericValue": 2, + "operator": "<=", + "transformed": true, + "type": "numeric", + }, + Object { + "attributeName": "numericDisjunctiveFacet", + "computedLabel": "≥ 3", + "name": "3", + "numericValue": 3, + "operator": ">=", + "transformed": true, + "type": "numeric", + }, + Object { + "attributeName": "numericDisjunctiveFacet", + "computedLabel": "≤ 4", + "name": "4", + "numericValue": 4, + "operator": "<=", + "transformed": true, + "type": "numeric", + }, + Object { + "attributeName": "_tags", + "computedLabel": "tag1", + "name": "tag1", + "transformed": true, + "type": "tag", + }, + Object { + "attributeName": "_tags", + "computedLabel": "tag2", + "name": "tag2", + "transformed": true, + "type": "tag", + }, + ] + } + shouldAutoHideContainer={false} + templateProps={ + Object { + "templates": Object { + "clearAll": "CLEAR ALL", + "footer": "FOOTER", + "header": "HEADER", + "item": "ITEM", + }, + "templatesConfig": Object { + "randomAttributeNeverUsed": "value", + }, + "transformData": undefined, + "useCustomCompileOptions": Object { + "clearAll": true, + "footer": true, + "header": true, + "item": true, + }, + } + } +/> +`; + exports[`currentRefinedValues() render() should render twice 1`] = ` { function setRefinementsInExpectedProps() { expectedProps.refinements = refinements; - expectedProps.clearRefinementClicks = map(refinements, () => () => {}); - expectedProps.clearRefinementURLs = map(refinements, () => '#cleared'); + expectedProps.clearRefinementClicks = refinements.map(() => () => {}); + expectedProps.clearRefinementURLs = refinements.map(() => '#cleared'); } beforeEach(() => { - ReactDOM = { render: sinon.spy() }; + ReactDOM = { render: jest.fn() }; currentRefinedValues.__Rewire__('render', ReactDOM.render); parameters = { @@ -634,11 +631,11 @@ describe('currentRefinedValues()', () => { widget.render(renderParameters); widget.render(renderParameters); - expect(ReactDOM.render.callCount).toBe(2); - expect(ReactDOM.render.firstCall.args[0]).toMatchSnapshot(); - expect(ReactDOM.render.firstCall.args[1]).toBe(parameters.container); - expect(ReactDOM.render.secondCall.args[0]).toMatchSnapshot(); - expect(ReactDOM.render.secondCall.args[1]).toBe(parameters.container); + expect(ReactDOM.render).toHaveBeenCalledTimes(2); + expect(ReactDOM.render.mock.calls[0][0]).toMatchSnapshot(); + expect(ReactDOM.render.mock.calls[0][1]).toBe(parameters.container); + expect(ReactDOM.render.mock.calls[1][0]).toMatchSnapshot(); + expect(ReactDOM.render.mock.calls[1][1]).toBe(parameters.container); }); describe('options.container', () => { @@ -652,9 +649,9 @@ describe('currentRefinedValues()', () => { const widget = currentRefinedValues(parameters); widget.init(initParameters); widget.render(renderParameters); - expect(ReactDOM.render.calledOnce).toBe(true); - expect(ReactDOM.render.firstCall.args[0]).toMatchSnapshot(); - expect(ReactDOM.render.firstCall.args[1]).toBe(element); + expect(ReactDOM.render).toHaveBeenCalledTimes(1); + expect(ReactDOM.render.mock.calls[0][0]).toMatchSnapshot(); + expect(ReactDOM.render.mock.calls[0][1]).toBe(element); }); it('should render with a HTMLElement container', () => { @@ -665,9 +662,9 @@ describe('currentRefinedValues()', () => { const widget = currentRefinedValues(parameters); widget.init(initParameters); widget.render(renderParameters); - expect(ReactDOM.render.calledOnce).toBe(true); - expect(ReactDOM.render.firstCall.args[0]).toMatchSnapshot(); - expect(ReactDOM.render.firstCall.args[1]).toBe(element); + expect(ReactDOM.render).toHaveBeenCalledTimes(1); + expect(ReactDOM.render.mock.calls[0][0]).toMatchSnapshot(); + expect(ReactDOM.render.mock.calls[0][1]).toBe(element); }); }); @@ -695,8 +692,8 @@ describe('currentRefinedValues()', () => { setRefinementsInExpectedProps(); expectedProps.attributes = {}; - expect(ReactDOM.render.calledOnce).toBe(true); - expect(ReactDOM.render.firstCall.args[0]).toMatchSnapshot(); + expect(ReactDOM.render).toHaveBeenCalledTimes(1); + expect(ReactDOM.render.mock.calls[0][0]).toMatchSnapshot(); }); it('should render all attributes with an empty array', () => { @@ -717,8 +714,8 @@ describe('currentRefinedValues()', () => { setRefinementsInExpectedProps(); expectedProps.attributes = {}; - expect(ReactDOM.render.calledOnce).toBe(true); - expect(ReactDOM.render.firstCall.args[0]).toMatchSnapshot(); + expect(ReactDOM.render).toHaveBeenCalledTimes(1); + expect(ReactDOM.render.mock.calls[0][0]).toMatchSnapshot(); }); it('should render and pass all attributes defined in each objects', () => { @@ -739,8 +736,7 @@ describe('currentRefinedValues()', () => { }, ]; - refinements = filter( - refinements, + refinements = refinements.filter( refinement => ['facet', 'facetExclude', 'disjunctiveFacet'].indexOf( refinement.attributeName @@ -769,8 +765,8 @@ describe('currentRefinedValues()', () => { }, }; - expect(ReactDOM.render.calledOnce).toBe(true); - expect(ReactDOM.render.firstCall.args[0]).toMatchSnapshot(); + expect(ReactDOM.render).toHaveBeenCalledTimes(1); + expect(ReactDOM.render.mock.calls[0][0]).toMatchSnapshot(); }); }); @@ -797,8 +793,8 @@ describe('currentRefinedValues()', () => { setRefinementsInExpectedProps(); expectedProps.attributes = {}; - expect(ReactDOM.render.calledOnce).toBe(true); - expect(ReactDOM.render.firstCall.args[0]).toMatchSnapshot(); + expect(ReactDOM.render).toHaveBeenCalledTimes(1); + expect(ReactDOM.render.mock.calls[0][0]).toMatchSnapshot(); }); it('should render all attributes with an empty array', () => { @@ -819,8 +815,8 @@ describe('currentRefinedValues()', () => { setRefinementsInExpectedProps(); expectedProps.attributes = {}; - expect(ReactDOM.render.calledOnce).toBe(true); - expect(ReactDOM.render.firstCall.args[0]).toMatchSnapshot(); + expect(ReactDOM.render).toHaveBeenCalledTimes(1); + expect(ReactDOM.render.mock.calls[0][0]).toMatchSnapshot(); }); it('should render and pass all attributes defined in each objects', () => { @@ -848,15 +844,13 @@ describe('currentRefinedValues()', () => { count: 42, exhaustive: true, }); - const firstRefinements = filter( - refinements, + const firstRefinements = refinements.filter( refinement => ['facet', 'facetExclude', 'disjunctiveFacet'].indexOf( refinement.attributeName ) !== -1 ); - const otherRefinements = filter( - refinements, + const otherRefinements = refinements.filter( refinement => ['facet', 'facetExclude', 'disjunctiveFacet'].indexOf( refinement.attributeName @@ -886,8 +880,8 @@ describe('currentRefinedValues()', () => { }, }; - expect(ReactDOM.render.calledOnce).toBe(true); - expect(ReactDOM.render.firstCall.args[0]).toMatchSnapshot(); + expect(ReactDOM.render).toHaveBeenCalledTimes(1); + expect(ReactDOM.render.mock.calls[0][0]).toMatchSnapshot(); }); }); @@ -921,15 +915,13 @@ describe('currentRefinedValues()', () => { count: 42, exhaustive: true, }); - const firstRefinements = filter( - refinements, + const firstRefinements = refinements.filter( refinement => ['facet', 'facetExclude', 'disjunctiveFacet'].indexOf( refinement.attributeName ) !== -1 ); - const otherRefinements = filter( - refinements, + const otherRefinements = refinements.filter( refinement => ['facet', 'facetExclude', 'disjunctiveFacet'].indexOf( refinement.attributeName @@ -959,8 +951,8 @@ describe('currentRefinedValues()', () => { }, }; - expect(ReactDOM.render.calledOnce).toBe(true); - expect(ReactDOM.render.firstCall.args[0]).toMatchSnapshot(); + expect(ReactDOM.render).toHaveBeenCalledTimes(1); + expect(ReactDOM.render.mock.calls[0][0]).toMatchSnapshot(); }); }); }); @@ -975,8 +967,8 @@ describe('currentRefinedValues()', () => { expectedProps.clearAllPosition = 'before'; - expect(ReactDOM.render.calledOnce).toBe(true); - expect(ReactDOM.render.firstCall.args[0]).toMatchSnapshot(); + expect(ReactDOM.render).toHaveBeenCalledTimes(1); + expect(ReactDOM.render.mock.calls[0][0]).toMatchSnapshot(); }); }); @@ -990,8 +982,24 @@ describe('currentRefinedValues()', () => { expectedProps.templateProps.templates.item = 'MY CUSTOM TEMPLATE'; - expect(ReactDOM.render.calledOnce).toBe(true); - expect(ReactDOM.render.firstCall.args[0]).toMatchSnapshot(); + expect(ReactDOM.render).toHaveBeenCalledTimes(1); + expect(ReactDOM.render.mock.calls[0][0]).toMatchSnapshot(); + }); + }); + + describe('options.transformItems', () => { + it('should transform passed items', () => { + const widget = currentRefinedValues({ + ...parameters, + transformItems: items => + items.map(item => ({ ...item, transformed: true })), + }); + + widget.init(initParameters); + widget.render(renderParameters); + + expect(ReactDOM.render).toHaveBeenCalledTimes(1); + expect(ReactDOM.render.mock.calls[0][0]).toMatchSnapshot(); }); }); @@ -1014,8 +1022,8 @@ describe('currentRefinedValues()', () => { widget.init(initParameters); widget.render(renderParameters); - expect(ReactDOM.render.calledOnce).toBe(true); - expect(ReactDOM.render.firstCall.args[0]).toMatchSnapshot(); + expect(ReactDOM.render).toHaveBeenCalledTimes(1); + expect(ReactDOM.render.mock.calls[0][0]).toMatchSnapshot(); }); it('shouldAutoHideContainer should be false with autoHideContainer = false', () => { @@ -1028,8 +1036,8 @@ describe('currentRefinedValues()', () => { expectedProps.shouldAutoHideContainer = false; - expect(ReactDOM.render.calledOnce).toBe(true); - expect(ReactDOM.render.firstCall.args[0]).toMatchSnapshot(); + expect(ReactDOM.render).toHaveBeenCalledTimes(1); + expect(ReactDOM.render.mock.calls[0][0]).toMatchSnapshot(); }); }); @@ -1043,8 +1051,8 @@ describe('currentRefinedValues()', () => { expectedProps.shouldAutoHideContainer = false; - expect(ReactDOM.render.calledOnce).toBe(true); - expect(ReactDOM.render.firstCall.args[0]).toMatchSnapshot(); + expect(ReactDOM.render).toHaveBeenCalledTimes(1); + expect(ReactDOM.render.mock.calls[0][0]).toMatchSnapshot(); }); it('shouldAutoHideContainer should be false with autoHideContainer = false', () => { @@ -1056,8 +1064,8 @@ describe('currentRefinedValues()', () => { expectedProps.shouldAutoHideContainer = false; - expect(ReactDOM.render.calledOnce).toBe(true); - expect(ReactDOM.render.firstCall.args[0]).toMatchSnapshot(); + expect(ReactDOM.render).toHaveBeenCalledTimes(1); + expect(ReactDOM.render.mock.calls[0][0]).toMatchSnapshot(); }); }); }); @@ -1073,8 +1081,8 @@ describe('currentRefinedValues()', () => { expectedProps.cssClasses.body = 'ais-current-refined-values--body custom-passed-body'; - expect(ReactDOM.render.calledOnce).toBe(true); - expect(ReactDOM.render.firstCall.args[0]).toMatchSnapshot(); + expect(ReactDOM.render).toHaveBeenCalledTimes(1); + expect(ReactDOM.render.mock.calls[0][0]).toMatchSnapshot(); }); it('should work with an array', () => { @@ -1087,8 +1095,8 @@ describe('currentRefinedValues()', () => { expectedProps.cssClasses.body = 'ais-current-refined-values--body custom-body custom-body-2'; - expect(ReactDOM.render.calledOnce).toBe(true); - expect(ReactDOM.render.firstCall.args[0]).toMatchSnapshot(); + expect(ReactDOM.render).toHaveBeenCalledTimes(1); + expect(ReactDOM.render.mock.calls[0][0]).toMatchSnapshot(); }); }); @@ -1107,19 +1115,19 @@ describe('currentRefinedValues()', () => { count: 42, exhaustive: true, }); - const firstRefinements = filter(refinements, { - attributeName: 'disjunctiveFacet', - }); - const secondRefinements = filter(refinements, { - attributeName: 'facetExclude', - }); - const otherRefinements = filter( - refinements, + const firstRefinements = refinements.filter( + refinement => refinement.attributeName === 'disjunctiveFacet' + ); + const secondRefinements = refinements.filter( + refinement => refinement.attributeName === 'facetExclude' + ); + const otherRefinements = refinements.filter( refinement => - ['disjunctiveFacet', 'facetExclude'].indexOf( + !['disjunctiveFacet', 'facetExclude'].includes( refinement.attributeName - ) === -1 + ) ); + refinements = [] .concat(firstRefinements) .concat(secondRefinements) @@ -1135,8 +1143,8 @@ describe('currentRefinedValues()', () => { facetExclude: { name: 'facetExclude' }, }; - expect(ReactDOM.render.calledOnce).toBe(true); - expect(ReactDOM.render.firstCall.args[0]).toMatchSnapshot(); + expect(ReactDOM.render).toHaveBeenCalledTimes(1); + expect(ReactDOM.render.mock.calls[0][0]).toMatchSnapshot(); }); }); diff --git a/src/widgets/current-refined-values/current-refined-values.js b/src/widgets/current-refined-values/current-refined-values.js index 06016874a6..bc02ff0d5c 100644 --- a/src/widgets/current-refined-values/current-refined-values.js +++ b/src/widgets/current-refined-values/current-refined-values.js @@ -91,8 +91,9 @@ currentRefinedValues({ [ transformData.{item} ], [ autoHideContainer = true ], [ cssClasses.{root, header, body, clearAll, list, item, link, count, footer} = {} ], - [ collapsible = false ] - [ clearsQuery = false ] + [ collapsible = false ], + [ clearsQuery = false ], + [ transformItems ] })`; /** @@ -148,6 +149,7 @@ currentRefinedValues({ * choose to hide the content of the widget. This option can also be an object with the property collapsed. If this * property is `true`, then the widget is hidden during the first rendering. * @property {boolean} [clearsQuery=false] If true, the clear all button also clears the active search query. + * @property {function(object[]):object[]} [transformItems] Function to transform the items passed to the templates. */ /** @@ -189,6 +191,7 @@ export default function currentRefinedValues({ cssClasses: userCssClasses = {}, collapsible = false, clearsQuery = false, + transformItems, }) { const transformDataOK = isUndefined(transformData) || @@ -278,6 +281,7 @@ export default function currentRefinedValues({ onlyListedAttributes, clearAll, clearsQuery, + transformItems, }); } catch (e) { throw new Error(usage); 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 17973c2050..84995fe448 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 @@ -102,6 +102,59 @@ exports[`hierarchicalMenu() render has a templates option 1`] = ` /> `; +exports[`hierarchicalMenu() render has a transformItems options 1`] = ` +{{label}} {{#helpers.formatNumber}}{{count}}{{/helpers.formatNumber}}", + }, + "templatesConfig": undefined, + "transformData": undefined, + "useCustomCompileOptions": Object { + "footer": false, + "header": false, + "item": false, + }, + } + } + toggleRefinement={[Function]} +/> +`; + exports[`hierarchicalMenu() render sets facetValues to empty array when no results 1`] = ` { @@ -12,7 +11,7 @@ describe('hierarchicalMenu()', () => { container = document.createElement('div'); attributes = ['hello', 'world']; options = {}; - ReactDOM = { render: sinon.spy() }; + ReactDOM = { render: jest.fn() }; hierarchicalMenu.__Rewire__('render', ReactDOM.render); }); @@ -139,13 +138,13 @@ describe('hierarchicalMenu()', () => { beforeEach(() => { data = { data: [{ name: 'foo' }, { name: 'bar' }] }; - results = { getFacetValues: sinon.spy(() => data) }; + results = { getFacetValues: jest.fn(() => data) }; helper = { - toggleRefinement: sinon.stub().returnsThis(), - search: sinon.spy(), + toggleRefinement: jest.fn().mockReturnThis(), + search: jest.fn(), }; state = { - toggleRefinement: sinon.spy(), + toggleRefinement: jest.fn(), }; options = { container, attributes }; createURL = () => '#'; @@ -167,41 +166,35 @@ describe('hierarchicalMenu()', () => { widget = hierarchicalMenu({ ...options, cssClasses: userCssClasses }); widget.init({ helper, createURL, instantSearchInstance: {} }); widget.render({ results, state }); - expect(ReactDOM.render.firstCall.args[0]).toMatchSnapshot(); + expect(ReactDOM.render.mock.calls[0][0]).toMatchSnapshot(); }); it('calls ReactDOM.render', () => { widget = hierarchicalMenu(options); widget.init({ helper, createURL, instantSearchInstance: {} }); widget.render({ results, state }); - expect(ReactDOM.render.calledOnce).toBe(true); - expect(ReactDOM.render.firstCall.args[0]).toMatchSnapshot(); + expect(ReactDOM.render).toHaveBeenCalledTimes(1); + expect(ReactDOM.render.mock.calls[0][0]).toMatchSnapshot(); }); it('asks for results.getFacetValues', () => { widget = hierarchicalMenu(options); widget.init({ helper, createURL, instantSearchInstance: {} }); widget.render({ results, state }); - expect(results.getFacetValues.calledOnce).toBe(true); - expect(results.getFacetValues.getCall(0).args).toEqual([ - 'hello', - { - sortBy: ['name:asc'], - }, - ]); + expect(results.getFacetValues).toHaveBeenCalledTimes(1); + expect(results.getFacetValues).toHaveBeenCalledWith('hello', { + sortBy: ['name:asc'], + }); }); it('has a sortBy option', () => { widget = hierarchicalMenu({ ...options, sortBy: ['count:asc'] }); widget.init({ helper, createURL, instantSearchInstance: {} }); widget.render({ results, state }); - expect(results.getFacetValues.calledOnce).toBe(true); - expect(results.getFacetValues.getCall(0).args).toEqual([ - 'hello', - { - sortBy: ['count:asc'], - }, - ]); + expect(results.getFacetValues).toHaveBeenCalledTimes(1); + expect(results.getFacetValues).toHaveBeenCalledWith('hello', { + sortBy: ['count:asc'], + }); }); it('has a templates option', () => { @@ -215,7 +208,20 @@ describe('hierarchicalMenu()', () => { }); widget.init({ helper, createURL, instantSearchInstance: {} }); widget.render({ results, state }); - expect(ReactDOM.render.firstCall.args[0]).toMatchSnapshot(); + expect(ReactDOM.render.mock.calls[0][0]).toMatchSnapshot(); + }); + + it('has a transformItems options', () => { + widget = hierarchicalMenu({ + ...options, + transformItems: items => + items.map(item => ({ ...item, transformed: true })), + }); + + widget.init({ helper, createURL, instantSearchInstance: {} }); + widget.render({ results, state }); + + expect(ReactDOM.render.mock.calls[0][0]).toMatchSnapshot(); }); it('sets shouldAutoHideContainer to true when no results', () => { @@ -223,7 +229,7 @@ describe('hierarchicalMenu()', () => { widget = hierarchicalMenu(options); widget.init({ helper, createURL, instantSearchInstance: {} }); widget.render({ results, state }); - expect(ReactDOM.render.firstCall.args[0]).toMatchSnapshot(); + expect(ReactDOM.render.mock.calls[0][0]).toMatchSnapshot(); }); it('sets facetValues to empty array when no results', () => { @@ -231,7 +237,7 @@ describe('hierarchicalMenu()', () => { widget = hierarchicalMenu(options); widget.init({ helper, createURL, instantSearchInstance: {} }); widget.render({ results, state }); - expect(ReactDOM.render.firstCall.args[0]).toMatchSnapshot(); + expect(ReactDOM.render.mock.calls[0][0]).toMatchSnapshot(); }); it('has a toggleRefinement method', () => { @@ -239,12 +245,11 @@ describe('hierarchicalMenu()', () => { widget.init({ helper, createURL, instantSearchInstance: {} }); widget.render({ results, state }); const elementToggleRefinement = - ReactDOM.render.firstCall.args[0].props.toggleRefinement; + ReactDOM.render.mock.calls[0][0].props.toggleRefinement; elementToggleRefinement('mom'); - expect(helper.toggleRefinement.calledOnce).toBe(true); - expect(helper.toggleRefinement.getCall(0).args).toEqual(['hello', 'mom']); - expect(helper.search.calledOnce).toBe(true); - expect(helper.toggleRefinement.calledBefore(helper.search)).toBe(true); + expect(helper.toggleRefinement).toHaveBeenCalledTimes(1); + expect(helper.toggleRefinement).toHaveBeenCalledWith('hello', 'mom'); + expect(helper.search).toHaveBeenCalledTimes(1); }); it('has a limit option', () => { @@ -281,7 +286,7 @@ describe('hierarchicalMenu()', () => { widget.init({ helper, createURL, instantSearchInstance: {} }); widget.render({ results, state }); const actualFacetValues = - ReactDOM.render.firstCall.args[0].props.facetValues; + ReactDOM.render.mock.calls[0][0].props.facetValues; expect(actualFacetValues).toEqual(expectedFacetValues); }); diff --git a/src/widgets/hierarchical-menu/hierarchical-menu.js b/src/widgets/hierarchical-menu/hierarchical-menu.js index d6aa62eb34..cad20eb166 100644 --- a/src/widgets/hierarchical-menu/hierarchical-menu.js +++ b/src/widgets/hierarchical-menu/hierarchical-menu.js @@ -64,7 +64,8 @@ hierarchicalMenu({ [ templates.{header, item, footer} ], [ transformData.{item} ], [ autoHideContainer=true ], - [ collapsible=false ] + [ collapsible=false ], + [ transformItems ] })`; /** * @typedef {Object} HierarchicalMenuCSSClasses @@ -131,6 +132,7 @@ hierarchicalMenu({ * @property {boolean|{collapsed: boolean}} [collapsible=false] Makes the widget collapsible. The user can then * choose to hide the content of the widget. This option can also be an object with the property collapsed. If this * property is `true`, then the widget is hidden during the first rendering. + * @property {function(object[]):object[]} [transformItems] Function to transform the items passed to the templates. */ /** @@ -200,6 +202,7 @@ export default function hierarchicalMenu({ templates = defaultTemplates, collapsible = false, transformData, + transformItems, } = {}) { if (!container || !attributes || !attributes.length) { throw new Error(usage); @@ -242,6 +245,7 @@ export default function hierarchicalMenu({ showParentLevel, limit, sortBy, + transformItems, }); } catch (e) { throw new Error(usage); diff --git a/src/widgets/hits-per-page-selector/__tests__/__snapshots__/hits-per-page-selector-test.js.snap b/src/widgets/hits-per-page-selector/__tests__/__snapshots__/hits-per-page-selector-test.js.snap index ac6c46d76f..55d5cb941a 100644 --- a/src/widgets/hits-per-page-selector/__tests__/__snapshots__/hits-per-page-selector-test.js.snap +++ b/src/widgets/hits-per-page-selector/__tests__/__snapshots__/hits-per-page-selector-test.js.snap @@ -28,3 +28,35 @@ exports[`hitsPerPageSelector() calls twice ReactDOM.render(, c shouldAutoHideContainer={false} /> `; + +exports[`hitsPerPageSelector() renders transformed items 1`] = ` + +`; diff --git a/src/widgets/hits-per-page-selector/__tests__/hits-per-page-selector-test.js b/src/widgets/hits-per-page-selector/__tests__/hits-per-page-selector-test.js index d0044e333f..71f02dd55a 100644 --- a/src/widgets/hits-per-page-selector/__tests__/hits-per-page-selector-test.js +++ b/src/widgets/hits-per-page-selector/__tests__/hits-per-page-selector-test.js @@ -1,4 +1,3 @@ -import sinon from 'sinon'; import hitsPerPageSelector from '../hits-per-page-selector'; describe('hitsPerPageSelector call', () => { @@ -25,10 +24,10 @@ describe('hitsPerPageSelector()', () => { let state; beforeEach(() => { - ReactDOM = { render: sinon.spy() }; + ReactDOM = { render: jest.fn() }; hitsPerPageSelector.__Rewire__('render', ReactDOM.render); - consoleWarn = sinon.stub(window.console, 'warn'); + consoleWarn = jest.spyOn(window.console, 'warn'); container = document.createElement('div'); items = [ @@ -45,8 +44,8 @@ describe('hitsPerPageSelector()', () => { state: { hitsPerPage: 20, }, - setQueryParameter: sinon.stub().returnsThis(), - search: sinon.spy(), + setQueryParameter: jest.fn().mockReturnThis(), + search: jest.fn(), }; state = { hitsPerPage: 10, @@ -80,26 +79,43 @@ describe('hitsPerPageSelector()', () => { widget.init({ helper, state: helper.state }); widget.render({ results, state }); widget.render({ results, state }); - expect(ReactDOM.render.callCount).toBe(2); - expect(ReactDOM.render.firstCall.args[0]).toMatchSnapshot(); + expect(ReactDOM.render).toHaveBeenCalledTimes(2); + expect(ReactDOM.render.mock.calls[0][0]).toMatchSnapshot(); + }); + + it('renders transformed items', () => { + widget = hitsPerPageSelector({ + container, + items: [ + { value: 10, label: '10 results' }, + { value: 20, label: '20 results', default: true }, + ], + transformItems: widgetItems => + widgetItems.map(item => ({ ...item, transformed: true })), + }); + + widget.init({ helper, state: helper.state }); + widget.render({ results, state }); + + expect(ReactDOM.render.mock.calls[0][0]).toMatchSnapshot(); }); it('sets the underlying hitsPerPage', () => { widget.init({ helper, state: helper.state }); widget.setHitsPerPage(helper, helper.state, 10); - expect(helper.setQueryParameter.calledOnce).toBe( - true, + expect(helper.setQueryParameter).toHaveBeenCalledTimes( + 1, 'setQueryParameter called once' ); - expect(helper.search.calledOnce).toBe(true, 'search called once'); + expect(helper.search).toHaveBeenCalledTimes(1, 'search called once'); }); it('should throw if there is no name attribute in a passed object', () => { items.length = 0; items.push({ label: 'Label without a value' }); widget.init({ state: helper.state, helper }); - expect(consoleWarn.calledOnce).toBe(true, 'console.warn called once'); - expect(consoleWarn.firstCall.args[0]).toEqual( + expect(consoleWarn).toHaveBeenCalledTimes(1, 'console.warn called once'); + expect(consoleWarn.mock.calls[0][0]).toEqual( `[Warning][hitsPerPageSelector] No item in \`items\` with \`value: hitsPerPage\` (hitsPerPage: 20)` ); @@ -108,8 +124,8 @@ describe('hitsPerPageSelector()', () => { it('must include the current hitsPerPage at initialization time', () => { helper.state.hitsPerPage = -1; widget.init({ state: helper.state, helper }); - expect(consoleWarn.calledOnce).toBe(true, 'console.warn called once'); - expect(consoleWarn.firstCall.args[0]).toEqual( + expect(consoleWarn).toHaveBeenCalledTimes(1, 'console.warn called once'); + expect(consoleWarn.mock.calls[0][0]).toEqual( `[Warning][hitsPerPageSelector] No item in \`items\` with \`value: hitsPerPage\` (hitsPerPage: -1)` ); @@ -124,6 +140,6 @@ describe('hitsPerPageSelector()', () => { afterEach(() => { hitsPerPageSelector.__ResetDependency__('render'); - consoleWarn.restore(); + consoleWarn.mockRestore(); }); }); diff --git a/src/widgets/hits-per-page-selector/hits-per-page-selector.js b/src/widgets/hits-per-page-selector/hits-per-page-selector.js index 607890443e..85cbe5e794 100644 --- a/src/widgets/hits-per-page-selector/hits-per-page-selector.js +++ b/src/widgets/hits-per-page-selector/hits-per-page-selector.js @@ -36,7 +36,8 @@ hitsPerPageSelector({ container, items, [ cssClasses.{root,select,item}={} ], - [ autoHideContainer=false ] + [ autoHideContainer=false ], + [ transformItems ] })`; /** @@ -59,6 +60,7 @@ hitsPerPageSelector({ * @property {HitsPerPageSelectorItems[]} items Array of objects defining the different values and labels. * @property {boolean} [autoHideContainer=false] Hide the container when no results match. * @property {HitsPerPageSelectorCSSClasses} [cssClasses] CSS classes to be added. + * @property {function(object[]):object[]} [transformItems] Function to transform the items passed to the templates. */ /** @@ -88,6 +90,7 @@ export default function hitsPerPageSelector({ items, cssClasses: userCssClasses = {}, autoHideContainer = false, + transformItems, } = {}) { if (!container) { throw new Error(usage); @@ -114,7 +117,7 @@ export default function hitsPerPageSelector({ specializedRenderer, () => unmountComponentAtNode(containerNode) ); - return makeHitsPerPageSelector({ items }); + return makeHitsPerPageSelector({ items, transformItems }); } catch (e) { throw new Error(usage); } diff --git a/src/widgets/hits/__tests__/__snapshots__/hits-test.js.snap b/src/widgets/hits/__tests__/__snapshots__/hits-test.js.snap index a7ec3df67c..36929b4314 100644 --- a/src/widgets/hits/__tests__/__snapshots__/hits-test.js.snap +++ b/src/widgets/hits/__tests__/__snapshots__/hits-test.js.snap @@ -87,3 +87,49 @@ exports[`hits() calls twice ReactDOM.render(, container) 2`] = ` } /> `; + +exports[`hits() renders transformed items 1`] = ` + +`; diff --git a/src/widgets/hits/__tests__/hits-test.js b/src/widgets/hits/__tests__/hits-test.js index c2381f2cb3..eec054473f 100644 --- a/src/widgets/hits/__tests__/hits-test.js +++ b/src/widgets/hits/__tests__/hits-test.js @@ -1,5 +1,3 @@ -import expect from 'expect'; -import sinon from 'sinon'; import hits from '../hits.js'; import defaultTemplates from '../defaultTemplates.js'; @@ -17,7 +15,7 @@ describe('hits()', () => { let results; beforeEach(() => { - ReactDOM = { render: sinon.spy() }; + ReactDOM = { render: jest.fn() }; hits.__Rewire__('render', ReactDOM.render); container = document.createElement('div'); @@ -36,11 +34,24 @@ describe('hits()', () => { widget.render({ results }); widget.render({ results }); - expect(ReactDOM.render.callCount).toBe(2); - expect(ReactDOM.render.firstCall.args[0]).toMatchSnapshot(); - expect(ReactDOM.render.firstCall.args[1]).toEqual(container); - expect(ReactDOM.render.secondCall.args[0]).toMatchSnapshot(); - expect(ReactDOM.render.secondCall.args[1]).toEqual(container); + expect(ReactDOM.render).toHaveBeenCalledTimes(2); + expect(ReactDOM.render.mock.calls[0][0]).toMatchSnapshot(); + expect(ReactDOM.render.mock.calls[0][1]).toEqual(container); + expect(ReactDOM.render.mock.calls[1][0]).toMatchSnapshot(); + expect(ReactDOM.render.mock.calls[1][1]).toEqual(container); + }); + + it('renders transformed items', () => { + widget = hits({ + container, + transformItems: items => + items.map(item => ({ ...item, transformed: true })), + }); + + widget.init({ instantSearchInstance: {} }); + widget.render({ results }); + + expect(ReactDOM.render.mock.calls[0][0]).toMatchSnapshot(); }); it('does not accept both item and allItems templates', () => { diff --git a/src/widgets/hits/hits.js b/src/widgets/hits/hits.js index 8636055e25..e994b7593e 100644 --- a/src/widgets/hits/hits.js +++ b/src/widgets/hits/hits.js @@ -47,6 +47,7 @@ const renderer = ({ const usage = `Usage: hits({ container, + [ transformItems ], [ cssClasses.{root,empty,item}={} ], [ templates.{empty,item} | templates.{empty, allItems} ], [ transformData.{empty,item} | transformData.{empty, allItems} ], @@ -80,6 +81,7 @@ hits({ * @property {HitsTransforms} [transformData] Method to change the object passed to the templates. * @property {HitsCSSClasses} [cssClasses] CSS classes to add. * @property {boolean} [escapeHits = false] Escape HTML entities from hits string values. + * @property {function(object[]):object[]} [transformItems] Function to transform the items passed to the templates. */ /** @@ -102,6 +104,7 @@ hits({ * item: 'Hit {{objectID}}: {{{_highlightResult.name.value}}}' * }, * escapeHits: true, + * transformItems: items => items.map(item => item), * }) * ); */ @@ -111,6 +114,7 @@ export default function hits({ templates = defaultTemplates, transformData, escapeHits = false, + transformItems, }) { if (!container) { throw new Error(`Must provide a container.${usage}`); @@ -139,7 +143,7 @@ export default function hits({ const makeHits = connectHits(specializedRenderer, () => unmountComponentAtNode(containerNode) ); - return makeHits({ escapeHits }); + return makeHits({ escapeHits, transformItems }); } catch (e) { throw new Error(usage); } diff --git a/src/widgets/infinite-hits/__tests__/__snapshots__/infinite-hits-test.js.snap b/src/widgets/infinite-hits/__tests__/__snapshots__/infinite-hits-test.js.snap index e44e64a2ea..9d3fb13117 100644 --- a/src/widgets/infinite-hits/__tests__/__snapshots__/infinite-hits-test.js.snap +++ b/src/widgets/infinite-hits/__tests__/__snapshots__/infinite-hits-test.js.snap @@ -199,3 +199,54 @@ exports[`infiniteHits() if it is the last page, then the props should contain is } /> `; + +exports[`infiniteHits() renders transformed items 1`] = ` + +`; diff --git a/src/widgets/infinite-hits/__tests__/infinite-hits-test.js b/src/widgets/infinite-hits/__tests__/infinite-hits-test.js index e95cc4bafe..f968b24c07 100644 --- a/src/widgets/infinite-hits/__tests__/infinite-hits-test.js +++ b/src/widgets/infinite-hits/__tests__/infinite-hits-test.js @@ -1,5 +1,3 @@ -import sinon from 'sinon'; -import expect from 'expect'; import algoliasearchHelper from 'algoliasearch-helper'; import infiniteHits from '../infinite-hits'; @@ -18,9 +16,9 @@ describe('infiniteHits()', () => { beforeEach(() => { helper = algoliasearchHelper({}); - helper.search = sinon.spy(); + helper.search = jest.fn(); - ReactDOM = { render: sinon.spy() }; + ReactDOM = { render: jest.fn() }; infiniteHits.__Rewire__('render', ReactDOM.render); container = document.createElement('div'); @@ -45,14 +43,32 @@ describe('infiniteHits()', () => { widget.render({ results, state }); widget.render({ results, state }); - expect(ReactDOM.render.calledTwice).toBe( - true, + expect(ReactDOM.render).toHaveBeenCalledTimes( + 2, 'ReactDOM.render called twice' ); - expect(ReactDOM.render.firstCall.args[0]).toMatchSnapshot(); - expect(ReactDOM.render.firstCall.args[1]).toEqual(container); - expect(ReactDOM.render.secondCall.args[0]).toMatchSnapshot(); - expect(ReactDOM.render.secondCall.args[1]).toEqual(container); + expect(ReactDOM.render.mock.calls[0][0]).toMatchSnapshot(); + expect(ReactDOM.render.mock.calls[0][1]).toEqual(container); + expect(ReactDOM.render.mock.calls[1][0]).toMatchSnapshot(); + expect(ReactDOM.render.mock.calls[1][1]).toEqual(container); + }); + + it('renders transformed items', () => { + const state = { page: 0 }; + widget = infiniteHits({ + container, + transformItems: items => + items.map(item => ({ ...item, transformed: true })), + }); + + widget.init({ helper, instantSearchInstance: {} }); + widget.render({ + results, + state, + instantSearchInstance: {}, + }); + + expect(ReactDOM.render.mock.calls[0][0]).toMatchSnapshot(); }); it('if it is the last page, then the props should contain isLastPage true', () => { @@ -66,14 +82,14 @@ describe('infiniteHits()', () => { state, }); - expect(ReactDOM.render.calledTwice).toBe( - true, + expect(ReactDOM.render).toHaveBeenCalledTimes( + 2, 'ReactDOM.render called twice' ); - expect(ReactDOM.render.firstCall.args[0]).toMatchSnapshot(); - expect(ReactDOM.render.firstCall.args[1]).toEqual(container); - expect(ReactDOM.render.secondCall.args[0]).toMatchSnapshot(); - expect(ReactDOM.render.secondCall.args[1]).toEqual(container); + expect(ReactDOM.render.mock.calls[0][0]).toMatchSnapshot(); + expect(ReactDOM.render.mock.calls[0][1]).toEqual(container); + expect(ReactDOM.render.mock.calls[1][0]).toMatchSnapshot(); + expect(ReactDOM.render.mock.calls[1][1]).toEqual(container); }); it('does not accept allItems templates', () => { @@ -96,7 +112,7 @@ describe('infiniteHits()', () => { widget.showMore(); expect(helper.state.page).toBe(1); - expect(helper.search.callCount).toBe(1); + expect(helper.search).toHaveBeenCalledTimes(1); }); afterEach(() => { diff --git a/src/widgets/infinite-hits/infinite-hits.js b/src/widgets/infinite-hits/infinite-hits.js index cb282081f6..01fe1dd2c7 100644 --- a/src/widgets/infinite-hits/infinite-hits.js +++ b/src/widgets/infinite-hits/infinite-hits.js @@ -53,6 +53,7 @@ Usage: infiniteHits({ container, [ escapeHits = false ], + [ transformItems ], [ showMoreLabel ], [ cssClasses.{root,empty,item,showmore,showmoreButton}={} ], [ templates.{empty,item} | templates.{empty} ], @@ -88,6 +89,7 @@ infiniteHits({ * @property {InfiniteHitsTransforms} [transformData] Method to change the object passed to the templates. * @property {InfiniteHitsCSSClasses} [cssClasses] CSS classes to add. * @property {boolean} [escapeHits = false] Escape HTML entities from hits string values. + * @property {function(object[]):object[]} [transformItems] Function to transform the items passed to the templates. */ /** @@ -110,6 +112,7 @@ infiniteHits({ * item: 'Hit {{objectID}}: {{{_highlightResult.name.value}}}' * }, * escapeHits: true, + * transformItems: items => items.map(item => item), * }) * ); */ @@ -120,6 +123,7 @@ export default function infiniteHits({ templates = defaultTemplates, transformData, escapeHits = false, + transformItems, } = {}) { if (!container) { throw new Error(`Must provide a container.${usage}`); @@ -155,7 +159,7 @@ export default function infiniteHits({ const makeInfiniteHits = connectInfiniteHits(specializedRenderer, () => unmountComponentAtNode(containerNode) ); - return makeInfiniteHits({ escapeHits }); + return makeInfiniteHits({ escapeHits, transformItems }); } catch (e) { throw new Error(usage); } diff --git a/src/widgets/menu-select/__tests__/__snapshots__/menu-select-test.js.snap b/src/widgets/menu-select/__tests__/__snapshots__/menu-select-test.js.snap index 4896e97392..9dc10401dc 100644 --- a/src/widgets/menu-select/__tests__/__snapshots__/menu-select-test.js.snap +++ b/src/widgets/menu-select/__tests__/__snapshots__/menu-select-test.js.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`menuSelect render correctly 1`] = ` +exports[`menuSelect render renders correctly 1`] = ` `; + +exports[`menuSelect render renders transformed items correctly 1`] = ` + +`; diff --git a/src/widgets/menu-select/__tests__/menu-select-test.js b/src/widgets/menu-select/__tests__/menu-select-test.js index 28f1e83c56..353d8bb72f 100644 --- a/src/widgets/menu-select/__tests__/menu-select-test.js +++ b/src/widgets/menu-select/__tests__/menu-select-test.js @@ -1,5 +1,3 @@ -import sinon from 'sinon'; - import menuSelect from '../menu-select'; describe('menuSelect', () => { @@ -13,26 +11,55 @@ describe('menuSelect', () => { expect(menuSelect.bind(null, { attributeName })).toThrow(/^Usage/); }); - it('render correctly', () => { - const data = { data: [{ name: 'foo' }, { name: 'bar' }] }; - const results = { getFacetValues: sinon.spy(() => data) }; - const state = { toggleRefinement: sinon.spy() }; - const helper = { - toggleRefinement: sinon.stub().returnsThis(), - search: sinon.spy(), - state, - }; - const createURL = () => '#'; - const ReactDOM = { render: sinon.spy() }; - menuSelect.__Rewire__('render', ReactDOM.render); - const widget = menuSelect({ - container: document.createElement('div'), - attributeName: 'test', + describe('render', () => { + let ReactDOM; + let data; + let results; + let state; + let helper; + + beforeEach(() => { + ReactDOM = { render: jest.fn() }; + menuSelect.__Rewire__('render', ReactDOM.render); + + data = { data: [{ name: 'foo' }, { name: 'bar' }] }; + results = { getFacetValues: jest.fn(() => data) }; + state = { toggleRefinement: jest.fn() }; + helper = { + toggleRefinement: jest.fn().mockReturnThis(), + search: jest.fn(), + state, + }; + }); + + it('renders correctly', () => { + const widget = menuSelect({ + container: document.createElement('div'), + attributeName: 'test', + }); + + widget.init({ helper, createURL: () => '#', instantSearchInstance: {} }); + widget.render({ results, createURL: () => '#', state }); + + expect(ReactDOM.render.mock.calls[0][0]).toMatchSnapshot(); + }); + + it('renders transformed items correctly', () => { + const widget = menuSelect({ + container: document.createElement('div'), + attributeName: 'test', + transformItems: items => + items.map(item => ({ ...item, transformed: true })), + }); + + widget.init({ helper, createURL: () => '#', instantSearchInstance: {} }); + widget.render({ results, createURL: () => '#', state }); + + expect(ReactDOM.render.mock.calls[0][0]).toMatchSnapshot(); + }); + + afterEach(() => { + menuSelect.__ResetDependency__('render'); }); - const instantSearchInstance = { templatesConfig: undefined }; - widget.init({ helper, createURL, instantSearchInstance }); - widget.render({ results, createURL, state }); - expect(ReactDOM.render.firstCall.args[0]).toMatchSnapshot(); - menuSelect.__ResetDependency__('render'); }); }); diff --git a/src/widgets/menu-select/menu-select.js b/src/widgets/menu-select/menu-select.js index d8c7323743..3347320f83 100644 --- a/src/widgets/menu-select/menu-select.js +++ b/src/widgets/menu-select/menu-select.js @@ -59,6 +59,7 @@ menuSelect({ [ templates.{header,item,footer,seeAllOption} ], [ transformData.{item} ], [ autoHideContainer ] + [ transformItems ] })`; /** @@ -95,6 +96,7 @@ menuSelect({ * @property {MenuSelectTransforms} [transformData] Set of functions to update the data before passing them to the templates. * @property {boolean} [autoHideContainer=true] Hide the container when there are no items in the menu select. * @property {MenuSelectCSSClasses} [cssClasses] CSS classes to add to the wrapping elements. + * @property {function(object[]):object[]} [transformItems] Function to transform the items passed to the templates. */ /** @@ -124,6 +126,7 @@ export default function menuSelect({ templates = defaultTemplates, transformData, autoHideContainer = true, + transformItems, }) { if (!container || !attributeName) { throw new Error(usage); @@ -149,7 +152,7 @@ export default function menuSelect({ try { const makeWidget = connectMenu(specializedRenderer); - return makeWidget({ attributeName, limit, sortBy }); + return makeWidget({ attributeName, limit, sortBy, transformItems }); } catch (e) { throw new Error(usage); } diff --git a/src/widgets/menu/__tests__/__snapshots__/menu-test.js.snap b/src/widgets/menu/__tests__/__snapshots__/menu-test.js.snap index be6e9bce33..20c39b0f6e 100644 --- a/src/widgets/menu/__tests__/__snapshots__/menu-test.js.snap +++ b/src/widgets/menu/__tests__/__snapshots__/menu-test.js.snap @@ -1,6 +1,64 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`menu snapshot 1`] = ` +exports[`menu render renders transformed items 1`] = ` +{{label}} {{#helpers.formatNumber}}{{count}}{{/helpers.formatNumber}}", + }, + "templatesConfig": undefined, + "transformData": undefined, + "useCustomCompileOptions": Object { + "footer": false, + "header": false, + "item": false, + }, + } + } + toggleRefinement={[Function]} + toggleShowMore={[Function]} +/> +`; + +exports[`menu render snapshot 1`] = ` { it('throws an exception when no attributeName', () => { const container = document.createElement('div'); @@ -13,26 +11,63 @@ describe('menu', () => { expect(menu.bind(null, { attributeName })).toThrow(/^Usage/); }); - it('snapshot', () => { - const data = { data: [{ name: 'foo' }, { name: 'bar' }] }; - const results = { getFacetValues: sinon.spy(() => data) }; - const state = { toggleRefinement: sinon.spy() }; - const helper = { - toggleRefinement: sinon.stub().returnsThis(), - search: sinon.spy(), - state, - }; - const createURL = () => '#'; - const ReactDOM = { render: sinon.spy() }; - menu.__Rewire__('render', ReactDOM.render); - const widget = menu({ - container: document.createElement('div'), - attributeName: 'test', + describe('render', () => { + let ReactDOM; + let data; + let results; + let state; + let helper; + + beforeEach(() => { + ReactDOM = { render: jest.fn() }; + menu.__Rewire__('render', ReactDOM.render); + + data = { data: [{ name: 'foo' }, { name: 'bar' }] }; + results = { getFacetValues: jest.fn(() => data) }; + state = { toggleRefinement: jest.fn() }; + helper = { + toggleRefinement: jest.fn().mockReturnThis(), + search: jest.fn(), + state, + }; + }); + + it('snapshot', () => { + const widget = menu({ + container: document.createElement('div'), + attributeName: 'test', + }); + + widget.init({ + helper, + createURL: () => '#', + instantSearchInstance: { templatesConfig: undefined }, + }); + widget.render({ results, state }); + + expect(ReactDOM.render.mock.calls[0][0]).toMatchSnapshot(); + }); + + it('renders transformed items', () => { + const widget = menu({ + container: document.createElement('div'), + attributeName: 'test', + transformItems: items => + items.map(item => ({ ...item, transformed: true })), + }); + + widget.init({ + helper, + createURL: () => '#', + instantSearchInstance: { templatesConfig: undefined }, + }); + widget.render({ results, state }); + + expect(ReactDOM.render.mock.calls[0][0]).toMatchSnapshot(); + }); + + afterEach(() => { + menu.__ResetDependency__('render'); }); - const instantSearchInstance = { templatesConfig: undefined }; - widget.init({ helper, createURL, instantSearchInstance }); - widget.render({ results, createURL, state }); - expect(ReactDOM.render.firstCall.args[0]).toMatchSnapshot(); - menu.__ResetDependency__('render'); }); }); diff --git a/src/widgets/menu/menu.js b/src/widgets/menu/menu.js index df968f903f..0f5f2cd3cd 100644 --- a/src/widgets/menu/menu.js +++ b/src/widgets/menu/menu.js @@ -82,7 +82,8 @@ menu({ [ transformData.{item} ], [ autoHideContainer ], [ showMore.{templates: {active, inactive}, limit} ], - [ collapsible=false ] + [ collapsible=false ], + [ transformItems ] })`; /** @@ -136,6 +137,7 @@ menu({ * @property {boolean} [autoHideContainer=true] Hide the container when there are no items in the menu. * @property {MenuCSSClasses} [cssClasses] CSS classes to add to the wrapping elements. * @property {boolean|{collapsible: boolean}} [collapsible=false] Hide the widget body and footer when clicking on header. + * @property {function(object[]):object[]} [transformItems] Function to transform the items passed to the templates. */ /** @@ -174,6 +176,7 @@ export default function menu({ transformData, autoHideContainer = true, showMore = false, + transformItems, }) { if (!container) { throw new Error(usage); @@ -220,7 +223,13 @@ export default function menu({ const makeWidget = connectMenu(specializedRenderer, () => unmountComponentAtNode(containerNode) ); - return makeWidget({ attributeName, limit, sortBy, showMoreLimit }); + return makeWidget({ + attributeName, + limit, + sortBy, + showMoreLimit, + transformItems, + }); } catch (e) { throw new Error(usage); } diff --git a/src/widgets/numeric-refinement-list/__tests__/__snapshots__/numeric-refinement-list-test.js.snap b/src/widgets/numeric-refinement-list/__tests__/__snapshots__/numeric-refinement-list-test.js.snap index 5cd3109531..ac3a73b639 100644 --- a/src/widgets/numeric-refinement-list/__tests__/__snapshots__/numeric-refinement-list-test.js.snap +++ b/src/widgets/numeric-refinement-list/__tests__/__snapshots__/numeric-refinement-list-test.js.snap @@ -137,3 +137,77 @@ exports[`numericRefinementList() calls twice ReactDOM.render( `; + +exports[`numericRefinementList() renders with transformed items 1`] = ` + + {{label}} +", + }, + "templatesConfig": undefined, + "transformData": undefined, + "useCustomCompileOptions": Object { + "footer": false, + "header": false, + "item": false, + }, + } + } + toggleRefinement={[Function]} +/> +`; diff --git a/src/widgets/numeric-refinement-list/__tests__/numeric-refinement-list-test.js b/src/widgets/numeric-refinement-list/__tests__/numeric-refinement-list-test.js index 1f6c98a0c9..df46b6728c 100644 --- a/src/widgets/numeric-refinement-list/__tests__/numeric-refinement-list-test.js +++ b/src/widgets/numeric-refinement-list/__tests__/numeric-refinement-list-test.js @@ -1,6 +1,3 @@ -import expect from 'expect'; -import sinon from 'sinon'; -import cloneDeep from 'lodash/cloneDeep'; import numericRefinementList from '../numeric-refinement-list.js'; const encodeValue = (start, end) => @@ -44,7 +41,7 @@ describe('numericRefinementList()', () => { let state; beforeEach(() => { - ReactDOM = { render: sinon.spy() }; + ReactDOM = { render: jest.fn() }; numericRefinementList.__Rewire__('render', ReactDOM.render); options = [ @@ -64,23 +61,23 @@ describe('numericRefinementList()', () => { }); helper = { state: { - getNumericRefinements: sinon.stub().returns([]), + getNumericRefinements: jest.fn().mockReturnValue([]), }, - addNumericRefinement: sinon.spy(), - search: sinon.spy(), - setState: sinon.stub().returnsThis(), + addNumericRefinement: jest.fn(), + search: jest.fn(), + setState: jest.fn().mockReturnThis(), }; state = { - getNumericRefinements: sinon.stub().returns([]), - clearRefinements: sinon.stub().returnsThis(), - addNumericRefinement: sinon.stub().returnsThis(), + getNumericRefinements: jest.fn().mockReturnValue([]), + clearRefinements: jest.fn().mockReturnThis(), + addNumericRefinement: jest.fn().mockReturnThis(), }; results = { hits: [], }; - helper.state.clearRefinements = sinon.stub().returns(helper.state); - helper.state.addNumericRefinement = sinon.stub().returns(helper.state); + helper.state.clearRefinements = jest.fn().mockReturnValue(helper.state); + helper.state.addNumericRefinement = jest.fn().mockReturnValue(helper.state); createURL = () => '#'; widget.init({ helper, instantSearchInstance: {} }); }); @@ -89,101 +86,121 @@ describe('numericRefinementList()', () => { widget.render({ state, results, createURL }); widget.render({ state, results, createURL }); - expect(ReactDOM.render.callCount).toBe(2); - expect(ReactDOM.render.firstCall.args[0]).toMatchSnapshot(); - expect(ReactDOM.render.firstCall.args[1]).toEqual(container); - expect(ReactDOM.render.secondCall.args[0]).toMatchSnapshot(); - expect(ReactDOM.render.secondCall.args[1]).toEqual(container); + expect(ReactDOM.render).toHaveBeenCalledTimes(2); + expect(ReactDOM.render.mock.calls[0][0]).toMatchSnapshot(); + expect(ReactDOM.render.mock.calls[0][1]).toEqual(container); + expect(ReactDOM.render.mock.calls[1][0]).toMatchSnapshot(); + expect(ReactDOM.render.mock.calls[1][1]).toEqual(container); + }); + + it('renders with transformed items', () => { + widget = numericRefinementList({ + container, + attributeName: 'price', + options, + transformItems: items => + items.map(item => ({ ...item, transformed: true })), + }); + + widget.init({ helper, instantSearchInstance: {} }); + widget.render({ state, results, createURL }); + + expect(ReactDOM.render.mock.calls[0][0]).toMatchSnapshot(); }); it("doesn't call the refinement functions if not refined", () => { widget.render({ state, results, createURL }); - expect(helper.state.clearRefinements.called).toBe( - false, + expect(helper.state.clearRefinements).toHaveBeenCalledTimes( + 0, 'clearRefinements called one' ); - expect(helper.state.addNumericRefinement.called).toBe( - false, + expect(helper.state.addNumericRefinement).toHaveBeenCalledTimes( + 0, 'addNumericRefinement never called' ); - expect(helper.search.called).toBe(false, 'search never called'); + expect(helper.search).toHaveBeenCalledTimes(0, 'search never called'); }); it('calls the refinement functions if refined with "4"', () => { widget._refine(encodeValue(4, 4)); - expect(helper.state.clearRefinements.calledOnce).toBe( - true, + expect(helper.state.clearRefinements).toHaveBeenCalledTimes( + 1, 'clearRefinements called once' ); - expect(helper.state.addNumericRefinement.calledOnce).toBe( - true, + expect(helper.state.addNumericRefinement).toHaveBeenCalledTimes( + 1, 'addNumericRefinement called once' ); - expect(helper.state.addNumericRefinement.getCall(0).args).toEqual([ + expect(helper.state.addNumericRefinement).toHaveBeenNthCalledWith( + 1, 'price', '=', - 4, - ]); - expect(helper.search.calledOnce).toBe(true, 'search called once'); + 4 + ); + expect(helper.search).toHaveBeenCalledTimes(1, 'search called once'); }); it('calls the refinement functions if refined with "between 5 and 10"', () => { widget._refine(encodeValue(5, 10)); - expect(helper.state.clearRefinements.calledOnce).toBe( - true, + expect(helper.state.clearRefinements).toHaveBeenCalledTimes( + 1, 'clearRefinements called once' ); - expect(helper.state.addNumericRefinement.calledTwice).toBe( - true, + expect(helper.state.addNumericRefinement).toHaveBeenCalledTimes( + 2, 'addNumericRefinement called twice' ); - expect(helper.state.addNumericRefinement.getCall(0).args).toEqual([ + expect(helper.state.addNumericRefinement).toHaveBeenNthCalledWith( + 1, 'price', '>=', - 5, - ]); - expect(helper.state.addNumericRefinement.getCall(1).args).toEqual([ + 5 + ); + expect(helper.state.addNumericRefinement).toHaveBeenNthCalledWith( + 2, 'price', '<=', - 10, - ]); - expect(helper.search.calledOnce).toBe(true, 'search called once'); + 10 + ); + expect(helper.search).toHaveBeenCalledTimes(1, 'search called once'); }); it('calls two times the refinement functions if refined with "less than 4"', () => { widget._refine(encodeValue(undefined, 4)); - expect(helper.state.clearRefinements.calledOnce).toBe( - true, + expect(helper.state.clearRefinements).toHaveBeenCalledTimes( + 1, 'clearRefinements called once' ); - expect(helper.state.addNumericRefinement.calledOnce).toBe( - true, + expect(helper.state.addNumericRefinement).toHaveBeenCalledTimes( + 1, 'addNumericRefinement called once' ); - expect(helper.state.addNumericRefinement.getCall(0).args).toEqual([ + expect(helper.state.addNumericRefinement).toHaveBeenNthCalledWith( + 1, 'price', '<=', - 4, - ]); - expect(helper.search.calledOnce).toBe(true, 'search called once'); + 4 + ); + expect(helper.search).toHaveBeenCalledTimes(1, 'search called once'); }); it('calls two times the refinement functions if refined with "more than 10"', () => { widget._refine(encodeValue(10)); - expect(helper.state.clearRefinements.calledOnce).toBe( - true, + expect(helper.state.clearRefinements).toHaveBeenCalledTimes( + 1, 'clearRefinements called once' ); - expect(helper.state.addNumericRefinement.calledOnce).toBe( - true, + expect(helper.state.addNumericRefinement).toHaveBeenCalledTimes( + 1, 'addNumericRefinement called once' ); - expect(helper.state.addNumericRefinement.getCall(0).args).toEqual([ + expect(helper.state.addNumericRefinement).toHaveBeenNthCalledWith( + 1, 'price', '>=', - 10, - ]); - expect(helper.search.calledOnce).toBe(true, 'search called once'); + 10 + ); + expect(helper.search).toHaveBeenCalledTimes(1, 'search called once'); }); it('does not alter the initial options when rendering', () => { @@ -193,7 +210,7 @@ describe('numericRefinementList()', () => { // Given const initialOptions = [{ start: 0, end: 5, name: '1-5' }]; - const initialOptionsClone = cloneDeep(initialOptions); + const initialOptionsClone = [...initialOptions]; const testWidget = numericRefinementList({ container, attributeName: 'price', diff --git a/src/widgets/numeric-refinement-list/numeric-refinement-list.js b/src/widgets/numeric-refinement-list/numeric-refinement-list.js index 356e97c220..3d90944ab1 100644 --- a/src/widgets/numeric-refinement-list/numeric-refinement-list.js +++ b/src/widgets/numeric-refinement-list/numeric-refinement-list.js @@ -58,7 +58,8 @@ numericRefinementList({ [ templates.{header,item,footer} ], [ transformData.{item} ], [ autoHideContainer ], - [ collapsible=false ] + [ collapsible=false ], + [ transformItems ] })`; /** @@ -103,6 +104,7 @@ numericRefinementList({ * @property {boolean} [autoHideContainer=true] Hide the container when no results match. * @property {NumericRefinementListCSSClasses} [cssClasses] CSS classes to add to the wrapping elements. * @property {boolean|{collapsible: boolean}} [collapsible=false] Hide the widget body and footer when clicking on header. + * @property {function(object[]):object[]} [transformItems] Function to transform the items passed to the templates. */ /** @@ -146,6 +148,7 @@ export default function numericRefinementList({ collapsible = false, transformData, autoHideContainer = true, + transformItems, } = {}) { if (!container || !attributeName || !options) { throw new Error(usage); @@ -179,7 +182,11 @@ export default function numericRefinementList({ specializedRenderer, () => unmountComponentAtNode(containerNode) ); - return makeNumericRefinementList({ attributeName, options }); + return makeNumericRefinementList({ + attributeName, + options, + transformItems, + }); } catch (e) { throw new Error(usage); } diff --git a/src/widgets/numeric-selector/numeric-selector.js b/src/widgets/numeric-selector/numeric-selector.js index 92677c5351..7c7d676b72 100644 --- a/src/widgets/numeric-selector/numeric-selector.js +++ b/src/widgets/numeric-selector/numeric-selector.js @@ -31,7 +31,8 @@ const usage = `Usage: numericSelector({ attributeName, options, cssClasses.{root,select,item}, - autoHideContainer + autoHideContainer, + transformItems })`; /** @@ -55,6 +56,7 @@ const usage = `Usage: numericSelector({ * @property {NumericOption[]} options Array of objects defining the different values and labels. * @property {string} [operator='='] The operator to use to refine. * @property {boolean} [autoHideContainer=false] Hide the container when no results match. + * @property {function(object[]):object[]} [transformItems] Function to transform the items passed to the templates. * @property {NumericSelectorCSSClasses} [cssClasses] CSS classes to be added. */ @@ -96,6 +98,7 @@ export default function numericSelector({ options, cssClasses: userCssClasses = {}, autoHideContainer = false, + transformItems, }) { const containerNode = getContainerNode(container); if (!container || !options || options.length === 0 || !attributeName) { @@ -121,7 +124,12 @@ export default function numericSelector({ specializedRenderer, () => unmountComponentAtNode(containerNode) ); - return makeNumericSelector({ operator, attributeName, options }); + return makeNumericSelector({ + operator, + attributeName, + options, + transformItems, + }); } catch (e) { throw new Error(usage); } diff --git a/src/widgets/refinement-list/__tests__/__snapshots__/refinement-list-test.js.snap b/src/widgets/refinement-list/__tests__/__snapshots__/refinement-list-test.js.snap new file mode 100644 index 0000000000..a25fdd6910 --- /dev/null +++ b/src/widgets/refinement-list/__tests__/__snapshots__/refinement-list-test.js.snap @@ -0,0 +1,78 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`refinementList() render renders transformed items correctly 1`] = ` + + + {{{highlighted}}} + {{#helpers.formatNumber}}{{count}}{{/helpers.formatNumber}} +", + }, + "templatesConfig": Object {}, + "transformData": undefined, + "useCustomCompileOptions": Object { + "footer": false, + "header": false, + "item": false, + }, + } + } + toggleRefinement={[Function]} + toggleShowMore={[Function]} +/> +`; diff --git a/src/widgets/refinement-list/__tests__/refinement-list-test.js b/src/widgets/refinement-list/__tests__/refinement-list-test.js index fa8e8e06b2..ec1bb97b68 100644 --- a/src/widgets/refinement-list/__tests__/refinement-list-test.js +++ b/src/widgets/refinement-list/__tests__/refinement-list-test.js @@ -1,5 +1,3 @@ -import sinon from 'sinon'; - import algoliasearchHelper from 'algoliasearch-helper'; const SearchParameters = algoliasearchHelper.SearchParameters; import refinementList from '../refinement-list.js'; @@ -16,11 +14,11 @@ describe('refinementList()', () => { beforeEach(() => { container = document.createElement('div'); - ReactDOM = { render: sinon.spy() }; + ReactDOM = { render: jest.fn() }; refinementList.__Rewire__('render', ReactDOM.render); - autoHideContainer = sinon.stub().returnsArg(0); + autoHideContainer = jest.fn(); refinementList.__Rewire__('autoHideContainerHOC', autoHideContainer); - headerFooter = sinon.stub().returnsArg(0); + headerFooter = jest.fn(); refinementList.__Rewire__('headerFooterHOC', headerFooter); }); @@ -52,9 +50,9 @@ describe('refinementList()', () => { beforeEach(() => { options = { container, attributeName: 'attributeName' }; results = { - getFacetValues: sinon - .stub() - .returns([{ name: 'foo' }, { name: 'bar' }]), + getFacetValues: jest + .fn() + .mockReturnValue([{ name: 'foo' }, { name: 'bar' }]), }; state = SearchParameters.make({}); createURL = () => '#'; @@ -78,7 +76,7 @@ describe('refinementList()', () => { // When renderWidget({ cssClasses }); - const actual = ReactDOM.render.firstCall.args[0].props.cssClasses; + const actual = ReactDOM.render.mock.calls[0][0].props.cssClasses; // Then expect(actual.root).toBe('ais-refinement-list root cx'); @@ -97,26 +95,26 @@ describe('refinementList()', () => { describe('autoHideContainer', () => { it('should set shouldAutoHideContainer to false if there are facetValues', () => { // Given - results.getFacetValues = sinon - .stub() - .returns([{ name: 'foo' }, { name: 'bar' }]); + results.getFacetValues = jest + .fn() + .mockReturnValue([{ name: 'foo' }, { name: 'bar' }]); // When renderWidget(); const actual = - ReactDOM.render.firstCall.args[0].props.shouldAutoHideContainer; + ReactDOM.render.mock.calls[0][0].props.shouldAutoHideContainer; // Then expect(actual).toBe(false); }); it('should set shouldAutoHideContainer to true if no facet values', () => { // Given - results.getFacetValues = sinon.stub().returns([]); + results.getFacetValues = jest.fn().mockReturnValue([]); // When renderWidget(); const actual = - ReactDOM.render.firstCall.args[0].props.shouldAutoHideContainer; + ReactDOM.render.mock.calls[0][0].props.shouldAutoHideContainer; // Then expect(actual).toBe(true); @@ -140,11 +138,11 @@ describe('refinementList()', () => { isRefined: false, }, ]; - results.getFacetValues = sinon.stub().returns(facetValues); + results.getFacetValues = jest.fn().mockReturnValue(facetValues); // When renderWidget(); - const props = ReactDOM.render.firstCall.args[0].props; + const props = ReactDOM.render.mock.calls[0][0].props; // Then expect(props.headerFooterData.header.refinedFacetsCount).toEqual(2); @@ -164,7 +162,7 @@ describe('refinementList()', () => { isRefined: false, }, ]; - results.getFacetValues = sinon.stub().returns(facetValues); + results.getFacetValues = jest.fn().mockReturnValue(facetValues); const renderOptions = { results, helper, state }; // When @@ -173,7 +171,7 @@ describe('refinementList()', () => { widget.render(renderOptions); // Then - let props = ReactDOM.render.firstCall.args[0].props; + let props = ReactDOM.render.mock.calls[0][0].props; expect(props.headerFooterData.header.refinedFacetsCount).toEqual(1); // When... second render call @@ -181,10 +179,27 @@ describe('refinementList()', () => { widget.render(renderOptions); // Then - props = ReactDOM.render.secondCall.args[0].props; + props = ReactDOM.render.mock.calls[1][0].props; expect(props.headerFooterData.header.refinedFacetsCount).toEqual(2); }); }); + + it('renders transformed items correctly', () => { + widget = refinementList({ + ...options, + transformItems: items => + items.map(item => ({ ...item, transformed: true })), + }); + + widget.init({ + helper, + createURL, + instantSearchInstance, + }); + widget.render({ results, helper, state }); + + expect(ReactDOM.render.mock.calls[0][0]).toMatchSnapshot(); + }); }); describe('show more', () => { diff --git a/src/widgets/refinement-list/refinement-list.js b/src/widgets/refinement-list/refinement-list.js index 76cb1ddd28..bfe0e73834 100644 --- a/src/widgets/refinement-list/refinement-list.js +++ b/src/widgets/refinement-list/refinement-list.js @@ -99,6 +99,7 @@ refinementList({ [ showMore.{templates: {active, inactive}, limit} ], [ collapsible=false ], [ searchForFacetValues.{placeholder, templates: {noResults}, isAlwaysActive, escapeFacetValues}], + [ transformItems ], })`; /** @@ -176,6 +177,7 @@ refinementList({ * @property {string} attributeName Name of the attribute for faceting. * @property {"and"|"or"} [operator="or"] How to apply refinements. Possible values: `or`, `and` * @property {string[]|function} [sortBy=["isRefined", "count:desc", "name:asc"]] How to sort refinements. Possible values: `count:asc` `count:desc` `name:asc` `name:desc` `isRefined`. + * @property {function(object[]):object[]} [transformItems] Function to transform the items passed to the templates. * * 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). * @property {number} [limit=10] How much facet values to get. When the show more feature is activated this is the minimum number of facets requested (the show more button is not in active state). @@ -238,6 +240,7 @@ export default function refinementList({ autoHideContainer = true, showMore = false, searchForFacetValues = false, + transformItems, } = {}) { if (!container) { throw new Error(usage); @@ -301,6 +304,7 @@ export default function refinementList({ showMoreLimit, sortBy, escapeFacetValues, + transformItems, }); } catch (e) { throw new Error(usage); diff --git a/src/widgets/sort-by-selector/__tests__/__snapshots__/sort-by-selector-test.js.snap b/src/widgets/sort-by-selector/__tests__/__snapshots__/sort-by-selector-test.js.snap index 7d580870c5..3aeb8f1b57 100644 --- a/src/widgets/sort-by-selector/__tests__/__snapshots__/sort-by-selector-test.js.snap +++ b/src/widgets/sort-by-selector/__tests__/__snapshots__/sort-by-selector-test.js.snap @@ -53,3 +53,32 @@ exports[`sortBySelector() calls twice ReactDOM.render(, contai shouldAutoHideContainer={false} /> `; + +exports[`sortBySelector() renders transformed items 1`] = ` + +`; diff --git a/src/widgets/sort-by-selector/__tests__/sort-by-selector-test.js b/src/widgets/sort-by-selector/__tests__/sort-by-selector-test.js index f88ded71bb..4fa34113ad 100644 --- a/src/widgets/sort-by-selector/__tests__/sort-by-selector-test.js +++ b/src/widgets/sort-by-selector/__tests__/sort-by-selector-test.js @@ -1,5 +1,3 @@ -import sinon from 'sinon'; -import expect from 'expect'; import sortBySelector from '../sort-by-selector'; import Selector from '../../../components/Selector'; @@ -34,8 +32,8 @@ describe('sortBySelector()', () => { indexName: 'defaultIndex', createAlgoliaClient: () => ({}), }); - autoHideContainer = sinon.stub().returns(Selector); - ReactDOM = { render: sinon.spy() }; + autoHideContainer = jest.fn().mockReturnValue(Selector); + ReactDOM = { render: jest.fn() }; sortBySelector.__Rewire__('render', ReactDOM.render); sortBySelector.__Rewire__('autoHideContainerHOC', autoHideContainer); @@ -52,9 +50,9 @@ describe('sortBySelector()', () => { }; widget = sortBySelector({ container, indices, cssClasses }); helper = { - getIndex: sinon.stub().returns('index-a'), - setIndex: sinon.stub().returnsThis(), - search: sinon.spy(), + getIndex: jest.fn().mockReturnValue('index-a'), + setIndex: jest.fn().mockReturnThis(), + search: jest.fn(), }; results = { @@ -71,20 +69,34 @@ describe('sortBySelector()', () => { it('calls twice ReactDOM.render(, container)', () => { widget.render({ helper, results }); widget.render({ helper, results }); - expect(ReactDOM.render.calledTwice).toBe( - true, + expect(ReactDOM.render).toHaveBeenCalledTimes( + 2, 'ReactDOM.render called twice' ); - expect(ReactDOM.render.firstCall.args[0]).toMatchSnapshot(); - expect(ReactDOM.render.firstCall.args[1]).toEqual(container); - expect(ReactDOM.render.secondCall.args[0]).toMatchSnapshot(); - expect(ReactDOM.render.secondCall.args[1]).toEqual(container); + expect(ReactDOM.render.mock.calls[0][0]).toMatchSnapshot(); + expect(ReactDOM.render.mock.calls[0][1]).toEqual(container); + expect(ReactDOM.render.mock.calls[1][0]).toMatchSnapshot(); + expect(ReactDOM.render.mock.calls[1][1]).toEqual(container); + }); + + it('renders transformed items', () => { + widget = sortBySelector({ + container, + indices, + transformItems: items => + items.map(item => ({ ...item, transformed: true })), + }); + + widget.init({ helper, instantSearchInstance: {} }); + widget.render({ helper, results }); + + expect(ReactDOM.render.mock.calls[0][0]).toMatchSnapshot(); }); it('sets the underlying index', () => { widget.setIndex('index-b'); - expect(helper.setIndex.calledOnce).toBe(true, 'setIndex called once'); - expect(helper.search.calledOnce).toBe(true, 'search called once'); + expect(helper.setIndex).toHaveBeenCalledTimes(1, 'setIndex called once'); + expect(helper.search).toHaveBeenCalledTimes(1, 'search called once'); }); it('should throw if there is no name attribute in a passed object', () => { @@ -96,7 +108,7 @@ describe('sortBySelector()', () => { }); it('must include the current index at initialization time', () => { - helper.getIndex = sinon.stub().returns('non-existing-index'); + helper.getIndex = jest.fn().mockReturnValue('non-existing-index'); expect(() => { widget.init({ helper }); }).toThrow(/Index non-existing-index not present/); diff --git a/src/widgets/sort-by-selector/sort-by-selector.js b/src/widgets/sort-by-selector/sort-by-selector.js index 458ce6ce43..40882f08cf 100644 --- a/src/widgets/sort-by-selector/sort-by-selector.js +++ b/src/widgets/sort-by-selector/sort-by-selector.js @@ -32,7 +32,8 @@ sortBySelector({ container, indices, [cssClasses.{root,select,item}={}], - [autoHideContainer=false] + [autoHideContainer=false], + [transformItems] })`; /** @@ -54,6 +55,7 @@ sortBySelector({ * @property {SortByIndexDefinition[]} indices Array of objects defining the different indices to choose from. * @property {boolean} [autoHideContainer=false] Hide the container when no results match. * @property {SortByWidgetCssClasses} [cssClasses] CSS classes to be added. + * @property {function(object[]):object[]} [transformItems] Function to transform the items passed to the templates. */ /** @@ -83,6 +85,7 @@ export default function sortBySelector({ indices, cssClasses: userCssClasses = {}, autoHideContainer = false, + transformItems, } = {}) { if (!container) { throw new Error(usage); @@ -108,7 +111,7 @@ export default function sortBySelector({ const makeWidget = connectSortBySelector(specializedRenderer, () => unmountComponentAtNode(containerNode) ); - return makeWidget({ indices }); + return makeWidget({ indices, transformItems }); } catch (e) { throw new Error(usage); }