diff --git a/docgen/src/guides/v3-migration.md b/docgen/src/guides/v3-migration.md index bea344d527..2cc4156cc5 100644 --- a/docgen/src/guides/v3-migration.md +++ b/docgen/src/guides/v3-migration.md @@ -84,6 +84,36 @@ search.addWidget( ); ``` +### `instantsearch.highlight` and `instantsearch.snippet` + +One powerful feature to demonstrate to users why a result matched their query is highlighting. InstantSearch was relying on some internals to support this inside the template of the widgets (see below). We now have two dedicated helpers to support both highlighting and snippetting. You can find more information about that [inside their documentation](LINK_NEW_DOC_). + +#### Previous usage + +```javascript +search.addWidget( + instantsearch.widget.hits({ + container: '#hits', + templates: { + item: '{{{ _highlightResult.name.value }}}', + }, + }) +); +``` + +#### New usage + +```javascript +search.addWidget( + instantsearch.widget.hits({ + container: '#hits', + templates: { + item: '{{#helpers.highlight}}{ "attribute": "name" }{{/helpers.highlight}}', + }, + }) +); +``` + ### `urlSync` is dropped If you were previously using the `urlSync` option, you should now migrate to the new `routing` feature. diff --git a/src/components/Hits/__tests__/Hits-test.js b/src/components/Hits/__tests__/Hits-test.js index ab2c37fc3a..c5ddf486e7 100644 --- a/src/components/Hits/__tests__/Hits-test.js +++ b/src/components/Hits/__tests__/Hits-test.js @@ -1,8 +1,9 @@ import React from 'react'; import { shallow, mount } from 'enzyme'; -import Hits from '../Hits'; -import Template from '../../Template/Template'; import { highlight } from '../../../helpers'; +import { TAG_REPLACEMENT } from '../../../lib/escape-highlight'; +import Template from '../../Template/Template'; +import Hits from '../Hits'; describe('Hits', () => { const cssClasses = { @@ -251,7 +252,9 @@ describe('Hits', () => { name: 'name 1', _highlightResult: { name: { - value: 'name 1', + value: `${TAG_REPLACEMENT.highlightPreTag}name 1${ + TAG_REPLACEMENT.highlightPostTag + }`, }, }, }, @@ -260,7 +263,9 @@ describe('Hits', () => { name: 'name 2', _highlightResult: { name: { - value: 'name 2', + value: `${TAG_REPLACEMENT.highlightPreTag}name 2${ + TAG_REPLACEMENT.highlightPostTag + }`, }, }, }, diff --git a/src/connectors/autocomplete/connectAutocomplete.js b/src/connectors/autocomplete/connectAutocomplete.js index 3d5c2627cb..603cd6166b 100644 --- a/src/connectors/autocomplete/connectAutocomplete.js +++ b/src/connectors/autocomplete/connectAutocomplete.js @@ -1,4 +1,4 @@ -import escapeHits, { tagConfig } from '../../lib/escape-highlight'; +import escapeHits, { TAG_PLACEHOLDER } from '../../lib/escape-highlight'; import { checkRendering } from '../../lib/utils'; const usage = `Usage: @@ -60,7 +60,7 @@ export default function connectAutocomplete(renderFn, unmountFn) { return { getConfiguration() { - return widgetParams.escapeHits ? tagConfig : undefined; + return widgetParams.escapeHits ? TAG_PLACEHOLDER : undefined; }, init({ instantSearchInstance, helper }) { diff --git a/src/connectors/hits/__tests__/connectHits-test.js b/src/connectors/hits/__tests__/connectHits-test.js index 1b5e9a155d..76ca3d7398 100644 --- a/src/connectors/hits/__tests__/connectHits-test.js +++ b/src/connectors/hits/__tests__/connectHits-test.js @@ -1,4 +1,5 @@ import jsHelper from 'algoliasearch-helper'; +import { TAG_PLACEHOLDER } from '../../../lib/escape-highlight.js'; const SearchResults = jsHelper.SearchResults; import connectHits from '../connectHits.js'; @@ -12,8 +13,8 @@ describe('connectHits', () => { const widget = makeWidget({ escapeHTML: true }); expect(widget.getConfiguration()).toEqual({ - highlightPreTag: '__ais-highlight__', - highlightPostTag: '__/ais-highlight__', + highlightPreTag: TAG_PLACEHOLDER.highlightPreTag, + highlightPostTag: TAG_PLACEHOLDER.highlightPostTag, }); // test if widget is not rendered yet at this point @@ -122,7 +123,9 @@ describe('connectHits', () => { { _highlightResult: { foobar: { - value: '', + value: ``, }, }, }, @@ -230,7 +233,9 @@ describe('connectHits', () => { name: 'hello', _highlightResult: { name: { - value: 'he__ais-highlight__llo__/ais-highlight__', + value: `he${TAG_PLACEHOLDER.highlightPreTag}llo${ + TAG_PLACEHOLDER.highlightPostTag + }`, }, }, }, @@ -238,7 +243,9 @@ describe('connectHits', () => { name: 'halloween', _highlightResult: { name: { - value: 'ha__ais-highlight__llo__/ais-highlight__ween', + value: `ha${TAG_PLACEHOLDER.highlightPreTag}llo${ + TAG_PLACEHOLDER.highlightPostTag + }ween`, }, }, }, diff --git a/src/connectors/hits/connectHits.js b/src/connectors/hits/connectHits.js index a0bc38ee10..1bdedbd7ea 100644 --- a/src/connectors/hits/connectHits.js +++ b/src/connectors/hits/connectHits.js @@ -1,4 +1,4 @@ -import escapeHTML, { tagConfig } from '../../lib/escape-highlight.js'; +import escapeHTML, { TAG_PLACEHOLDER } from '../../lib/escape-highlight.js'; import { checkRendering } from '../../lib/utils.js'; const usage = `Usage: @@ -66,7 +66,7 @@ export default function connectHits(renderFn, unmountFn) { return { getConfiguration() { - return widgetParams.escapeHTML ? tagConfig : undefined; + return widgetParams.escapeHTML ? TAG_PLACEHOLDER : undefined; }, init({ instantSearchInstance }) { diff --git a/src/connectors/infinite-hits/__tests__/connectInfiniteHits-test.js b/src/connectors/infinite-hits/__tests__/connectInfiniteHits-test.js index b28b5a83db..9df41559d4 100644 --- a/src/connectors/infinite-hits/__tests__/connectInfiniteHits-test.js +++ b/src/connectors/infinite-hits/__tests__/connectInfiniteHits-test.js @@ -1,4 +1,5 @@ import jsHelper from 'algoliasearch-helper'; +import { TAG_PLACEHOLDER } from '../../../lib/escape-highlight.js'; const SearchResults = jsHelper.SearchResults; import connectInfiniteHits from '../connectInfiniteHits.js'; @@ -14,8 +15,8 @@ describe('connectInfiniteHits', () => { }); expect(widget.getConfiguration()).toEqual({ - highlightPostTag: '__/ais-highlight__', - highlightPreTag: '__ais-highlight__', + highlightPreTag: TAG_PLACEHOLDER.highlightPreTag, + highlightPostTag: TAG_PLACEHOLDER.highlightPostTag, }); // test if widget is not rendered yet at this point @@ -173,7 +174,9 @@ describe('connectInfiniteHits', () => { { _highlightResult: { foobar: { - value: '', + value: ``, }, }, }, @@ -284,7 +287,9 @@ describe('connectInfiniteHits', () => { name: 'hello', _highlightResult: { name: { - value: 'he__ais-highlight__llo__/ais-highlight__', + value: `he${TAG_PLACEHOLDER.highlightPreTag}llo${ + TAG_PLACEHOLDER.highlightPostTag + }`, }, }, }, @@ -292,7 +297,9 @@ describe('connectInfiniteHits', () => { name: 'halloween', _highlightResult: { name: { - value: 'ha__ais-highlight__llo__/ais-highlight__ween', + value: `ha${TAG_PLACEHOLDER.highlightPreTag}llo${ + TAG_PLACEHOLDER.highlightPostTag + }ween`, }, }, }, diff --git a/src/connectors/infinite-hits/connectInfiniteHits.js b/src/connectors/infinite-hits/connectInfiniteHits.js index bdcd717ba6..7b5b10c62d 100644 --- a/src/connectors/infinite-hits/connectInfiniteHits.js +++ b/src/connectors/infinite-hits/connectInfiniteHits.js @@ -1,4 +1,4 @@ -import escapeHTML, { tagConfig } from '../../lib/escape-highlight.js'; +import escapeHTML, { TAG_PLACEHOLDER } from '../../lib/escape-highlight.js'; import { checkRendering } from '../../lib/utils.js'; const usage = `Usage: @@ -88,7 +88,7 @@ export default function connectInfiniteHits(renderFn, unmountFn) { return { getConfiguration() { - return widgetParams.escapeHTML ? tagConfig : undefined; + return widgetParams.escapeHTML ? TAG_PLACEHOLDER : undefined; }, init({ instantSearchInstance, helper }) { diff --git a/src/connectors/refinement-list/__tests__/connectRefinementList-test.js b/src/connectors/refinement-list/__tests__/connectRefinementList-test.js index 6686016f82..25f6781ca6 100644 --- a/src/connectors/refinement-list/__tests__/connectRefinementList-test.js +++ b/src/connectors/refinement-list/__tests__/connectRefinementList-test.js @@ -2,7 +2,7 @@ import jsHelper, { SearchResults, SearchParameters, } from 'algoliasearch-helper'; -import { tagConfig } from '../../../lib/escape-highlight.js'; +import { TAG_PLACEHOLDER } from '../../../lib/escape-highlight.js'; import connectRefinementList from '../connectRefinementList.js'; @@ -917,7 +917,7 @@ describe('connectRefinementList', () => { const helper = jsHelper({}, '', { ...widget.getConfiguration({}), // Here we simulate that another widget has set some highlight tags - ...tagConfig, + ...TAG_PLACEHOLDER, }); helper.search = jest.fn(); helper.searchForFacetValues = jest.fn().mockReturnValue( @@ -1021,7 +1021,7 @@ describe('connectRefinementList', () => { const helper = jsHelper({}, '', { ...widget.getConfiguration({}), // Here we simulate that another widget has set some highlight tags - ...tagConfig, + ...TAG_PLACEHOLDER, }); helper.search = jest.fn(); helper.searchForFacetValues = jest.fn().mockReturnValue( @@ -1030,15 +1030,15 @@ describe('connectRefinementList', () => { facetHits: [ { count: 33, - highlighted: `Salvador ${tagConfig.highlightPreTag}Da${ - tagConfig.highlightPostTag + highlighted: `Salvador ${TAG_PLACEHOLDER.highlightPreTag}Da${ + TAG_PLACEHOLDER.highlightPostTag }li`, value: 'Salvador Dali', }, { count: 9, - highlighted: `${tagConfig.highlightPreTag}Da${ - tagConfig.highlightPostTag + highlighted: `${TAG_PLACEHOLDER.highlightPreTag}Da${ + TAG_PLACEHOLDER.highlightPostTag }vidoff`, value: 'Davidoff', }, @@ -1094,7 +1094,7 @@ describe('connectRefinementList', () => { expect(sffvQuery).toBe('da'); expect(sffvFacet).toBe('category'); expect(maxNbItems).toBe(2); - expect(paramOverride).toEqual(tagConfig); + expect(paramOverride).toEqual(TAG_PLACEHOLDER); return Promise.resolve().then(() => { expect(rendering).toHaveBeenCalledTimes(3); diff --git a/src/connectors/refinement-list/connectRefinementList.js b/src/connectors/refinement-list/connectRefinementList.js index 1d27b0402a..15ba6211a3 100644 --- a/src/connectors/refinement-list/connectRefinementList.js +++ b/src/connectors/refinement-list/connectRefinementList.js @@ -1,5 +1,9 @@ import { checkRendering } from '../../lib/utils.js'; -import { tagConfig, escapeFacets } from '../../lib/escape-highlight.js'; +import { + escapeFacets, + TAG_PLACEHOLDER, + TAG_REPLACEMENT, +} from '../../lib/escape-highlight.js'; import isEqual from 'lodash/isEqual'; const usage = `Usage: @@ -256,11 +260,11 @@ export default function connectRefinementList(renderFn, unmountFn) { } else { const tags = { highlightPreTag: escapeFacetValues - ? tagConfig.highlightPreTag - : '', + ? TAG_PLACEHOLDER.highlightPreTag + : TAG_REPLACEMENT.highlightPreTag, highlightPostTag: escapeFacetValues - ? tagConfig.highlightPostTag - : '', + ? TAG_PLACEHOLDER.highlightPostTag + : TAG_REPLACEMENT.highlightPostTag, }; helper diff --git a/src/helpers/__tests__/highlight-test.js b/src/helpers/__tests__/highlight-test.js index e472994ac6..78bb1de89a 100644 --- a/src/helpers/__tests__/highlight-test.js +++ b/src/helpers/__tests__/highlight-test.js @@ -22,20 +22,21 @@ const hit = { objectID: '5477500', _highlightResult: { name: { - value: 'Amazon - Fire TV Stick with Alexa Voice Remote - Black', + value: + 'Amazon - Fire TV Stick with Alexa Voice Remote - Black', matchLevel: 'full', fullyHighlighted: false, matchedWords: ['amazon'], }, description: { value: - 'Enjoy smart access to videos, games and apps with this Amazon Fire TV stick. Its Alexa voice remote lets you deliver hands-free commands when you want to watch television or engage with other applications. With a quad-core processor, 1GB internal memory and 8GB of storage, this portable Amazon Fire TV stick works fast for buffer-free streaming.', + 'Enjoy smart access to videos, games and apps with this Amazon Fire TV stick. Its Alexa voice remote lets you deliver hands-free commands when you want to watch television or engage with other applications. With a quad-core processor, 1GB internal memory and 8GB of storage, this portable Amazon Fire TV stick works fast for buffer-free streaming.', matchLevel: 'full', fullyHighlighted: false, matchedWords: ['amazon'], }, brand: { - value: 'Amazon', + value: 'Amazon', matchLevel: 'full', fullyHighlighted: true, matchedWords: ['amazon'], @@ -59,7 +60,7 @@ const hit = { }, meta: { name: { - value: 'Nested Amazon name', + value: 'Nested Amazon name', }, }, }, diff --git a/src/helpers/__tests__/snippet-test.js b/src/helpers/__tests__/snippet-test.js index 22017a7cff..e05a9d1374 100644 --- a/src/helpers/__tests__/snippet-test.js +++ b/src/helpers/__tests__/snippet-test.js @@ -22,20 +22,21 @@ const hit = { objectID: '5477500', _snippetResult: { name: { - value: 'Amazon - Fire TV Stick with Alexa Voice Remote - Black', + value: + 'Amazon - Fire TV Stick with Alexa Voice Remote - Black', matchLevel: 'full', fullyHighlighted: false, matchedWords: ['amazon'], }, description: { value: - 'Enjoy smart access to videos, games and apps with this Amazon Fire TV stick. Its Alexa voice remote lets you deliver hands-free commands when you want to watch television or engage with other applications. With a quad-core processor, 1GB internal memory and 8GB of storage, this portable Amazon Fire TV stick works fast for buffer-free streaming.', + 'Enjoy smart access to videos, games and apps with this Amazon Fire TV stick. Its Alexa voice remote lets you deliver hands-free commands when you want to watch television or engage with other applications. With a quad-core processor, 1GB internal memory and 8GB of storage, this portable Amazon Fire TV stick works fast for buffer-free streaming.', matchLevel: 'full', fullyHighlighted: false, matchedWords: ['amazon'], }, brand: { - value: 'Amazon', + value: 'Amazon', matchLevel: 'full', fullyHighlighted: true, matchedWords: ['amazon'], @@ -59,7 +60,7 @@ const hit = { }, meta: { name: { - value: 'Nested Amazon name', + value: 'Nested Amazon name', }, }, }, diff --git a/src/helpers/highlight.js b/src/helpers/highlight.js index 7ef955be34..8628b40778 100644 --- a/src/helpers/highlight.js +++ b/src/helpers/highlight.js @@ -1,4 +1,5 @@ import { getPropertyByPath } from '../lib/utils'; +import { TAG_REPLACEMENT } from '../lib/escape-highlight'; import { component } from '../lib/suit'; const suit = component('Highlight'); @@ -11,12 +12,17 @@ export default function highlight({ const attributeValue = getPropertyByPath(hit, `_highlightResult.${attribute}.value`) || ''; + const className = suit({ + descendantName: 'highlighted', + }); + return attributeValue .replace( - //g, - `<${highlightedTagName} class="${suit({ - descendantName: 'highlighted', - })}">` + new RegExp(TAG_REPLACEMENT.highlightPreTag, 'g'), + `<${highlightedTagName} class="${className}">` ) - .replace(/<\/em>/g, ``); + .replace( + new RegExp(TAG_REPLACEMENT.highlightPostTag, 'g'), + `` + ); } diff --git a/src/helpers/snippet.js b/src/helpers/snippet.js index 66f4ef3e6c..c20f9fa402 100644 --- a/src/helpers/snippet.js +++ b/src/helpers/snippet.js @@ -1,4 +1,5 @@ import { getPropertyByPath } from '../lib/utils'; +import { TAG_REPLACEMENT } from '../lib/escape-highlight'; import { component } from '../lib/suit'; const suit = component('Snippet'); @@ -11,12 +12,17 @@ export default function snippet({ const attributeValue = getPropertyByPath(hit, `_snippetResult.${attribute}.value`) || ''; + const className = suit({ + descendantName: 'highlighted', + }); + return attributeValue .replace( - //g, - `<${highlightedTagName} class="${suit({ - descendantName: 'highlighted', - })}">` + new RegExp(TAG_REPLACEMENT.highlightPreTag, 'g'), + `<${highlightedTagName} class="${className}">` ) - .replace(/<\/em>/g, ``); + .replace( + new RegExp(TAG_REPLACEMENT.highlightPostTag, 'g'), + `` + ); } diff --git a/src/lib/escape-highlight.js b/src/lib/escape-highlight.js index 7dfe5a5288..e25b5b0a17 100644 --- a/src/lib/escape-highlight.js +++ b/src/lib/escape-highlight.js @@ -3,15 +3,26 @@ import escape from 'lodash/escape'; import isArray from 'lodash/isArray'; import isPlainObject from 'lodash/isPlainObject'; -export const tagConfig = { +export const TAG_PLACEHOLDER = { highlightPreTag: '__ais-highlight__', highlightPostTag: '__/ais-highlight__', }; +export const TAG_REPLACEMENT = { + highlightPreTag: '', + highlightPostTag: '', +}; + function replaceTagsAndEscape(value) { return escape(value) - .replace(new RegExp(tagConfig.highlightPreTag, 'g'), '') - .replace(new RegExp(tagConfig.highlightPostTag, 'g'), ''); + .replace( + new RegExp(TAG_PLACEHOLDER.highlightPreTag, 'g'), + TAG_REPLACEMENT.highlightPreTag + ) + .replace( + new RegExp(TAG_PLACEHOLDER.highlightPostTag, 'g'), + TAG_REPLACEMENT.highlightPostTag + ); } function recursiveEscape(input) { diff --git a/src/widgets/infinite-hits/__tests__/infinite-hits-test.js b/src/widgets/infinite-hits/__tests__/infinite-hits-test.js index 0a8a4af0d5..b805255daa 100644 --- a/src/widgets/infinite-hits/__tests__/infinite-hits-test.js +++ b/src/widgets/infinite-hits/__tests__/infinite-hits-test.js @@ -1,4 +1,5 @@ import algoliasearchHelper from 'algoliasearch-helper'; +import { TAG_PLACEHOLDER } from '../../../lib/escape-highlight.js'; import infiniteHits from '../infinite-hits'; describe('infiniteHits call', () => { @@ -33,8 +34,8 @@ describe('infiniteHits()', () => { it('It does have a specific configuration', () => { expect(widget.getConfiguration()).toEqual({ - highlightPostTag: '__/ais-highlight__', - highlightPreTag: '__ais-highlight__', + highlightPreTag: TAG_PLACEHOLDER.highlightPreTag, + highlightPostTag: TAG_PLACEHOLDER.highlightPostTag, }); });