From 3f52784862b72ef59acfc0735fe482cbfa6ad1f5 Mon Sep 17 00:00:00 2001 From: Yannick Croissant Date: Wed, 20 Jan 2021 11:28:01 +0100 Subject: [PATCH] feat(ratingMenu): Add support for floats in values (#4611) --- .../__tests__/connectRatingMenu-test.js | 117 +++++++++----- .../rating-menu/connectRatingMenu.js | 144 +++++++++++++----- .../rating-menu/__tests__/rating-menu-test.js | 27 ++-- 3 files changed, 194 insertions(+), 94 deletions(-) diff --git a/src/connectors/rating-menu/__tests__/connectRatingMenu-test.js b/src/connectors/rating-menu/__tests__/connectRatingMenu-test.js index d4f57728ec..f7e107c16f 100644 --- a/src/connectors/rating-menu/__tests__/connectRatingMenu-test.js +++ b/src/connectors/rating-menu/__tests__/connectRatingMenu-test.js @@ -169,9 +169,8 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/rating-menu expect(helper.getRefinements(attribute)).toEqual([]); refine('3'); expect(helper.getRefinements(attribute)).toEqual([ - { type: 'disjunctive', value: '3' }, - { type: 'disjunctive', value: '4' }, - { type: 'disjunctive', value: '5' }, + { operator: '<=', type: 'numeric', value: [5] }, + { operator: '>=', type: 'numeric', value: [3] }, ]); expect(helper.search).toHaveBeenCalledTimes(1); @@ -229,14 +228,13 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/rating-menu }, ]); expect(helper.getRefinements(attribute)).toEqual([ - { type: 'disjunctive', value: '3' }, - { type: 'disjunctive', value: '4' }, - { type: 'disjunctive', value: '5' }, + { operator: '<=', type: 'numeric', value: [5] }, + { operator: '>=', type: 'numeric', value: [3] }, ]); refine('4'); expect(helper.getRefinements(attribute)).toEqual([ - { type: 'disjunctive', value: '4' }, - { type: 'disjunctive', value: '5' }, + { operator: '<=', type: 'numeric', value: [5] }, + { operator: '>=', type: 'numeric', value: [4] }, ]); expect(helper.search).toHaveBeenCalledTimes(2); } @@ -252,9 +250,8 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/rating-menu expect(helper.getRefinements(attribute)).toEqual([]); refine('3'); expect(helper.getRefinements(attribute)).toEqual([ - { type: 'disjunctive', value: '3' }, - { type: 'disjunctive', value: '4' }, - { type: 'disjunctive', value: '5' }, + { operator: '<=', type: 'numeric', value: [5] }, + { operator: '>=', type: 'numeric', value: [3] }, ]); expect(helper.search).toHaveBeenCalledTimes(1); @@ -278,13 +275,17 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/rating-menu // Second rendering expect(helper.getRefinements(attribute)).toEqual([ - { type: 'disjunctive', value: '3' }, - { type: 'disjunctive', value: '4' }, - { type: 'disjunctive', value: '5' }, + { operator: '<=', type: 'numeric', value: [5] }, + { operator: '>=', type: 'numeric', value: [3] }, ]); refine('3'); - expect(helper.getRefinements(attribute)).toEqual([]); - expect(helper.state.disjunctiveFacetsRefinements).toEqual({ swag: [] }); + expect(helper.getRefinements(attribute)).toEqual([ + { operator: '<=', type: 'numeric', value: [] }, + { operator: '>=', type: 'numeric', value: [] }, + ]); + expect(helper.state.numericRefinements).toEqual({ + swag: { '<=': [], '>=': [] }, + }); expect(helper.search).toHaveBeenCalledTimes(2); }); @@ -313,8 +314,10 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/rating-menu const attribute = 'grade'; const helper = jsHelper({}, indexName, { disjunctiveFacets: [attribute], - disjunctiveFacetsRefinements: { - [attribute]: [4, 5], + numericRefinements: { + [attribute]: { + '>=': [4], + }, }, }); helper.search = jest.fn(); @@ -327,8 +330,10 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/rating-menu new SearchParameters({ index: indexName, disjunctiveFacets: [attribute], - disjunctiveFacetsRefinements: { - grade: [4, 5], + numericRefinements: { + grade: { + '>=': [4], + }, }, }) ); @@ -338,6 +343,12 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/rating-menu expect(nextState).toEqual( new SearchParameters({ index: indexName, + disjunctiveFacets: [attribute], + numericRefinements: { + grade: { + '>=': [], + }, + }, }) ); }); @@ -368,8 +379,10 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/rating-menu const makeWidget = connectRatingMenu(render); const helper = jsHelper({}, '', { disjunctiveFacets: ['grade'], - disjunctiveFacetsRefinements: { - grade: ['2', '3', '4', '5'], + numericRefinements: { + grade: { + '>=': [2], + }, }, }); @@ -397,8 +410,10 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/rating-menu const makeWidget = connectRatingMenu(render); const helper = jsHelper({}, '', { disjunctiveFacets: ['grade'], - disjunctiveFacetsRefinements: { - grade: ['2', '3', '4', '5'], + numericRefinements: { + grade: { + '>=': [2], + }, }, }); @@ -437,8 +452,10 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/rating-menu }); const helper = jsHelper({}, 'indexName', { disjunctiveFacets: ['grade'], - disjunctiveFacetsRefinements: { - grade: ['2', '3', '4'], + numericRefinements: { + grade: { + '>=': [2], + }, }, }); @@ -469,8 +486,10 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/rating-menu }); const helper = jsHelper({}, 'indexName', { disjunctiveFacets: ['grade'], - disjunctiveFacetsRefinements: { - grade: ['2', '3', '4'], + numericRefinements: { + grade: { + '>=': [2], + }, }, }); @@ -548,8 +567,10 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/rating-menu }); const helper = jsHelper({}, 'indexName', { disjunctiveFacets: ['grade'], - disjunctiveFacetsRefinements: { - grade: ['2', '3', '4'], + numericRefinements: { + grade: { + '>=': [2], + }, }, }); @@ -578,8 +599,10 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/rating-menu }); const helper = jsHelper({}, 'indexName', { disjunctiveFacets: ['grade'], - disjunctiveFacetsRefinements: { - grade: ['2', '3', '4'], + numericRefinements: { + grade: { + '>=': [2], + }, }, }); @@ -662,7 +685,7 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/rating-menu new SearchParameters({ index: '', disjunctiveFacets: ['grade'], - disjunctiveFacetsRefinements: { + numericRefinements: { grade: [], }, }) @@ -673,8 +696,10 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/rating-menu const render = () => {}; const makeWidget = connectRatingMenu(render); const helper = jsHelper({}, '', { - disjunctiveFacetsRefinements: { - grade: ['2', '3', '4', '5'], + numericRefinements: { + grade: { + '>=': [2], + }, }, }); @@ -690,7 +715,7 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/rating-menu new SearchParameters({ index: '', disjunctiveFacets: ['grade'], - disjunctiveFacetsRefinements: { + numericRefinements: { grade: [], }, }) @@ -717,8 +742,11 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/rating-menu new SearchParameters({ index: '', disjunctiveFacets: ['grade'], - disjunctiveFacetsRefinements: { - grade: ['3', '4', '5'], + numericRefinements: { + grade: { + '<=': [5], + '>=': [3], + }, }, }) ); @@ -729,8 +757,10 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/rating-menu const makeWidget = connectRatingMenu(render); const helper = jsHelper({}, '', { disjunctiveFacets: ['grade'], - disjunctiveFacetsRefinements: { - grade: ['1', '2', '3', '4', '5'], + numericRefinements: { + grade: { + '>=': [1], + }, }, }); @@ -750,8 +780,11 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/rating-menu new SearchParameters({ index: '', disjunctiveFacets: ['grade'], - disjunctiveFacetsRefinements: { - grade: ['3', '4', '5'], + numericRefinements: { + grade: { + '<=': [5], + '>=': [3], + }, }, }) ); diff --git a/src/connectors/rating-menu/connectRatingMenu.js b/src/connectors/rating-menu/connectRatingMenu.js index 1d20f334ab..a6ab5e2171 100644 --- a/src/connectors/rating-menu/connectRatingMenu.js +++ b/src/connectors/rating-menu/connectRatingMenu.js @@ -1,8 +1,9 @@ import { checkRendering, + createDocumentationLink, createDocumentationMessageGenerator, - range, noop, + warning, } from '../../lib/utils'; const withUsage = createDocumentationMessageGenerator({ @@ -12,6 +13,9 @@ const withUsage = createDocumentationMessageGenerator({ const $$type = 'ais.ratingMenu'; +const MAX_VALUES_PER_FACET_API_LIMIT = 1000; +const STEP = 1; + const createSendEvent = ({ instantSearchInstance, helper, @@ -137,23 +141,71 @@ export default function connectRatingMenu(renderFn, unmountFn = noop) { } const getRefinedStar = state => { - const refinements = state.getDisjunctiveRefinements(attribute); + const values = state.getNumericRefinements(attribute); - if (!refinements.length) { + if (!values['>=']?.length) { return undefined; } - return Math.min(...refinements.map(Number)); + return values['>='][0]; + }; + + const getFacetsMaxDecimalPlaces = facetResults => { + let maxDecimalPlaces = 0; + facetResults.forEach(facetResult => { + const [, decimal = ''] = facetResult.name.split('.'); + maxDecimalPlaces = Math.max(maxDecimalPlaces, decimal.length); + }); + return maxDecimalPlaces; + }; + + const getFacetValuesWarningMessage = ({ + maxDecimalPlaces, + maxFacets, + maxValuesPerFacet, + }) => { + const maxDecimalPlacesInRange = Math.max( + 0, + Math.floor(Math.log10(MAX_VALUES_PER_FACET_API_LIMIT / max)) + ); + const maxFacetsInRange = Math.min( + MAX_VALUES_PER_FACET_API_LIMIT, + Math.pow(10, maxDecimalPlacesInRange) * max + ); + + const solutions = []; + + if (maxFacets > MAX_VALUES_PER_FACET_API_LIMIT) { + solutions.push( + `- Update your records to lower the precision of the values in the "${attribute}" attribute (for example: ${(5.123456789).toPrecision( + maxDecimalPlaces + 1 + )} to ${(5.123456789).toPrecision(maxDecimalPlacesInRange + 1)})` + ); + } + if (maxValuesPerFacet < maxFacetsInRange) { + solutions.push( + `- Increase the maximum number of facet values to ${maxFacetsInRange} using the "configure" widget ${createDocumentationLink( + { name: 'configure' } + )} and the "maxValuesPerFacet" parameter https://www.algolia.com/doc/api-reference/api-parameters/maxValuesPerFacet/` + ); + } + + return `The ${attribute} attribute can have ${maxFacets} different values (0 to ${max} with a maximum of ${maxDecimalPlaces} decimals = ${maxFacets}) but you retrieved only ${maxValuesPerFacet} facet values. Therefore the number of results that match the refinements can be incorrect. +${ + solutions.length + ? `To resolve this problem you can:\n${solutions.join('\n')}` + : `` +}`; }; const toggleRefinement = (helper, facetValue) => { sendEvent('click', facetValue); const isRefined = getRefinedStar(helper.state) === Number(facetValue); - helper.removeDisjunctiveFacetRefinement(attribute); + helper.removeNumericRefinement(attribute); if (!isRefined) { - for (let val = Number(facetValue); val <= max; ++val) { - helper.addDisjunctiveFacetRefinement(attribute, val); - } + helper + .addNumericRefinement(attribute, '<=', max) + .addNumericRefinement(attribute, '>=', facetValue); } helper.search(); }; @@ -161,7 +213,12 @@ export default function connectRatingMenu(renderFn, unmountFn = noop) { const connectorState = { toggleRefinementFactory: helper => toggleRefinement.bind(this, helper), createURLFactory: ({ state, createURL }) => value => - createURL(state.toggleRefinement(attribute, value)), + createURL( + state + .removeNumericRefinement(attribute) + .addNumericRefinement(attribute, '<=', max) + .addNumericRefinement(attribute, '>=', value) + ), }; return { @@ -208,7 +265,7 @@ export default function connectRatingMenu(renderFn, unmountFn = noop) { instantSearchInstance, createURL, }) { - const facetValues = []; + let facetValues = []; if (!sendEvent) { sendEvent = createSendEvent({ @@ -220,40 +277,51 @@ export default function connectRatingMenu(renderFn, unmountFn = noop) { } if (results) { - const allValues = {}; - for (let v = max; v >= 0; --v) { - allValues[v] = 0; - } - (results.getFacetValues(attribute) || []).forEach(facet => { - const val = Math.round(facet.name); - if (!val || val > max) { - return; - } - for (let v = val; v >= 1; --v) { - allValues[v] += facet.count; - } - }); + const facetResults = results.getFacetValues(attribute); + const maxValuesPerFacet = facetResults.length; + + const maxDecimalPlaces = getFacetsMaxDecimalPlaces(facetResults); + const maxFacets = Math.pow(10, maxDecimalPlaces) * max; + + warning( + maxFacets <= maxValuesPerFacet, + getFacetValuesWarningMessage({ + maxDecimalPlaces, + maxFacets, + maxValuesPerFacet, + }) + ); + const refinedStar = getRefinedStar(state); - for (let star = max - 1; star >= 1; --star) { - const count = allValues[star]; - if (refinedStar && star !== refinedStar && count === 0) { + + for (let star = STEP; star < max; star += STEP) { + const isRefined = refinedStar === star; + + const count = facetResults + .filter(f => Number(f.name) >= star && Number(f.name) <= max) + .map(f => f.count) + .reduce((sum, current) => sum + current, 0); + + if (refinedStar && !isRefined && count === 0) { // skip count==0 when at least 1 refinement is enabled // eslint-disable-next-line no-continue continue; } - const stars = []; - for (let i = 1; i <= max; ++i) { - stars.push(i <= star); - } + + const stars = [...new Array(Math.floor(max / STEP))].map( + (v, i) => i * STEP < star + ); + facetValues.push({ stars, name: String(star), value: String(star), count, - isRefined: refinedStar === star, + isRefined, }); } } + facetValues = facetValues.reverse(); return { items: facetValues, @@ -268,7 +336,7 @@ export default function connectRatingMenu(renderFn, unmountFn = noop) { dispose({ state }) { unmountFn(); - return state.removeDisjunctiveFacet(attribute); + return state.removeNumericRefinement(attribute); }, getWidgetUiState(uiState, { searchParameters }) { @@ -297,18 +365,16 @@ export default function connectRatingMenu(renderFn, unmountFn = noop) { if (!value) { return withDisjunctiveFacet.setQueryParameters({ - disjunctiveFacetsRefinements: { - ...withDisjunctiveFacet.disjunctiveFacetsRefinements, + numericRefinements: { + ...withDisjunctiveFacet.numericRefinements, [attribute]: [], }, }); } - return range({ start: Number(value), end: max + 1 }).reduce( - (parameters, number) => - parameters.addDisjunctiveFacetRefinement(attribute, number), - withDisjunctiveFacet - ); + return withDisjunctiveFacet + .addNumericRefinement(attribute, '<=', max) + .addNumericRefinement(attribute, '>=', value); }, }; }; diff --git a/src/widgets/rating-menu/__tests__/rating-menu-test.js b/src/widgets/rating-menu/__tests__/rating-menu-test.js index 6ea4fc832f..06b175b505 100644 --- a/src/widgets/rating-menu/__tests__/rating-menu-test.js +++ b/src/widgets/rating-menu/__tests__/rating-menu-test.js @@ -51,9 +51,9 @@ describe('ratingMenu()', () => { }) ); jest.spyOn(helper, 'clearRefinements'); - jest.spyOn(helper, 'addDisjunctiveFacetRefinement'); + jest.spyOn(helper, 'addNumericRefinement'); jest.spyOn(helper, 'getRefinements'); - jest.spyOn(helper, 'removeDisjunctiveFacetRefinement'); + jest.spyOn(helper, 'removeNumericRefinement'); helper.search = jest.fn(); results = { @@ -63,6 +63,7 @@ describe('ratingMenu()', () => { createURL = () => '#'; widget.init({ helper, + state: helper.state, instantSearchInstance: createInstantSearch({ templatesConfig: undefined, }), @@ -75,7 +76,7 @@ describe('ratingMenu()', () => { ).toEqual( new SearchParameters({ disjunctiveFacets: ['anAttrName'], - disjunctiveFacetsRefinements: { + numericRefinements: { anAttrName: [], }, }) @@ -97,7 +98,7 @@ describe('ratingMenu()', () => { }); it('hide the count==0 when there is a refinement', () => { - helper.addDisjunctiveFacetRefinement(attribute, 1); + helper.addNumericRefinement(attribute, '>=', 1); const _results = new SearchResults(helper.state, [ { facets: { @@ -133,7 +134,7 @@ describe('ratingMenu()', () => { widget.render({ state: helper.state, helper, results, createURL }); expect(helper.clearRefinements).toHaveBeenCalledTimes(0); - expect(helper.addDisjunctiveFacetRefinement).toHaveBeenCalledTimes(0); + expect(helper.addNumericRefinement).toHaveBeenCalledTimes(0); expect(helper.search).toHaveBeenCalledTimes(0); }); @@ -143,20 +144,20 @@ describe('ratingMenu()', () => { .getWidgetRenderState({ state: helper.state, helper, results, createURL }) .refine('3'); - expect(helper.removeDisjunctiveFacetRefinement).toHaveBeenCalledTimes(1); - expect(helper.addDisjunctiveFacetRefinement).toHaveBeenCalledTimes(3); + expect(helper.removeNumericRefinement).toHaveBeenCalledTimes(1); + expect(helper.addNumericRefinement).toHaveBeenCalledTimes(2); expect(helper.search).toHaveBeenCalledTimes(1); }); it('toggles the refinements', () => { - helper.addDisjunctiveFacetRefinement(attribute, 2); - helper.addDisjunctiveFacetRefinement.mockReset(); + helper.addNumericRefinement(attribute, '>=', 2); + helper.addNumericRefinement.mockReset(); widget .getWidgetRenderState({ state: helper.state, helper, results, createURL }) .refine('2'); - expect(helper.removeDisjunctiveFacetRefinement).toHaveBeenCalledTimes(1); - expect(helper.addDisjunctiveFacetRefinement).toHaveBeenCalledTimes(0); + expect(helper.removeNumericRefinement).toHaveBeenCalledTimes(1); + expect(helper.addNumericRefinement).toHaveBeenCalledTimes(0); expect(helper.search).toHaveBeenCalledTimes(1); }); @@ -166,8 +167,8 @@ describe('ratingMenu()', () => { .getWidgetRenderState({ state: helper.state, helper, results, createURL }) .refine('4'); - expect(helper.removeDisjunctiveFacetRefinement).toHaveBeenCalledTimes(1); - expect(helper.addDisjunctiveFacetRefinement).toHaveBeenCalledTimes(2); + expect(helper.removeNumericRefinement).toHaveBeenCalledTimes(1); + expect(helper.addNumericRefinement).toHaveBeenCalledTimes(2); expect(helper.search).toHaveBeenCalledTimes(1); });