diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c53104713..efa76accb1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,14 @@ +# [4.10.0](https://github.com/algolia/instantsearch.js/compare/v4.9.2...v4.10.0) (2021-01-05) + + +### Features + +* **index:** expose createURL ([#4603](https://github.com/algolia/instantsearch.js/issues/4603)) ([f57e9c5](https://github.com/algolia/instantsearch.js/commit/f57e9c5a46e927b8dd38f167ee5c467151334a08)) +* **index:** expose scoped results getter ([#4609](https://github.com/algolia/instantsearch.js/issues/4609)) ([a41b1e4](https://github.com/algolia/instantsearch.js/commit/a41b1e46bb195e6ef1f9bdbdde64d9300246c22f)) +* **reverseHighlight/reverseSnippet:** Implements reverseHighlight and reverseSnippet ([#4592](https://github.com/algolia/instantsearch.js/issues/4592)) ([718bf45](https://github.com/algolia/instantsearch.js/commit/718bf458152bb55bab1efb542adb8e31298c0c3c)) + + + ## [4.9.2](https://github.com/algolia/instantsearch.js/compare/v4.9.1...v4.9.2) (2020-12-15) diff --git a/package.json b/package.json index 2e88d4874b..df0b54b0f8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "instantsearch.js", - "version": "4.9.2", + "version": "4.10.0", "description": "InstantSearch.js is a JavaScript library for building performant and instant search experiences with Algolia.", "homepage": "https://www.algolia.com/doc/guides/building-search-ui/what-is-instantsearch/js/", "types": "es/index.d.ts", @@ -143,7 +143,7 @@ "bundlesize": [ { "path": "./dist/instantsearch.production.min.js", - "maxSize": "65.25 kB" + "maxSize": "65.5 kB" }, { "path": "./dist/instantsearch.development.js", diff --git a/src/connectors/hierarchical-menu/__tests__/connectHierarchicalMenu-test.js b/src/connectors/hierarchical-menu/__tests__/connectHierarchicalMenu-test.js index 68cd39b3aa..0dfac06701 100644 --- a/src/connectors/hierarchical-menu/__tests__/connectHierarchicalMenu-test.js +++ b/src/connectors/hierarchical-menu/__tests__/connectHierarchicalMenu-test.js @@ -1401,7 +1401,7 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/hierarchica insightsMethod: 'clickedFilters', payload: { eventName: 'Filter Applied', - filters: ['category:"value"'], + filters: ['category:value'], index: '', }, widgetType: 'ais.hierarchicalMenu', diff --git a/src/connectors/menu/__tests__/connectMenu-test.js b/src/connectors/menu/__tests__/connectMenu-test.js index 9a832b9c5c..1a7c3478d1 100644 --- a/src/connectors/menu/__tests__/connectMenu-test.js +++ b/src/connectors/menu/__tests__/connectMenu-test.js @@ -1244,7 +1244,7 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/menu/js/#co insightsMethod: 'clickedFilters', payload: { eventName: 'Filter Applied', - filters: ['category:"value"'], + filters: ['category:value'], index: '', }, widgetType: 'ais.menu', diff --git a/src/connectors/refinement-list/__tests__/connectRefinementList-test.js b/src/connectors/refinement-list/__tests__/connectRefinementList-test.js index 1144423d8f..d6c3e60088 100644 --- a/src/connectors/refinement-list/__tests__/connectRefinementList-test.js +++ b/src/connectors/refinement-list/__tests__/connectRefinementList-test.js @@ -2864,7 +2864,7 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/refinement- insightsMethod: 'clickedFilters', payload: { eventName: 'Filter Applied', - filters: ['category:"value"'], + filters: ['category:value'], index: '', }, widgetType: 'ais.refinementList', diff --git a/src/connectors/toggle-refinement/connectToggleRefinement.js b/src/connectors/toggle-refinement/connectToggleRefinement.js index 358007b1dc..cccc3d3143 100644 --- a/src/connectors/toggle-refinement/connectToggleRefinement.js +++ b/src/connectors/toggle-refinement/connectToggleRefinement.js @@ -35,7 +35,7 @@ const createSendEvent = ({ instantSearchInstance, attribute, on, helper }) => ( payload: { eventName, index: helper.getIndex(), - filters: on.map(value => `${attribute}:${JSON.stringify(value)}`), + filters: on.map(value => `${attribute}:${value}`), }, }); } diff --git a/src/helpers/__tests__/reverseHighlight-test.ts b/src/helpers/__tests__/reverseHighlight-test.ts new file mode 100644 index 0000000000..abd9316803 --- /dev/null +++ b/src/helpers/__tests__/reverseHighlight-test.ts @@ -0,0 +1,203 @@ +import reverseHighlight from '../reverseHighlight'; + +const NONE = 'none' as const; +const FULL = 'full' as const; + +/* eslint-disable @typescript-eslint/camelcase */ +const hit = { + name: 'Amazon - Fire TV Stick with Alexa Voice Remote - Black', + description: + '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.', + brand: 'Amazon', + categories: ['TV & Home Theater', 'Streaming Media Players'], + hierarchicalCategories: { + lvl0: 'TV & Home Theater', + lvl1: 'TV & Home Theater > Streaming Media Players', + }, + type: 'Streaming - (media plyr)', + price: 39.99, + price_range: '1 - 50', + image: 'https://cdn-demo.algolia.com/bestbuy-0118/5477500_sb.jpg', + url: 'https://api.bestbuy.com/click/-/5477500/pdp', + free_shipping: false, + rating: 4, + popularity: 21469, + objectID: '5477500', + _highlightResult: { + name: { + 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.', + matchLevel: FULL, + fullyHighlighted: false, + matchedWords: ['amazon', 'processor', '1GB'], + }, + brand: { + value: 'Amazon', + matchLevel: FULL, + fullyHighlighted: true, + matchedWords: ['amazon'], + }, + categories: [ + { + value: 'TV & Home Theater', + matchLevel: NONE, + matchedWords: [], + }, + { + value: 'Streaming Media Players', + matchLevel: NONE, + matchedWords: [], + }, + ], + type: { + value: 'Streaming - (media plyr)', + matchLevel: FULL, + fullyHighlighted: false, + matchedWords: ['streaming', 'media'], + }, + typeMissingSibling: { + value: 'Streaming - (media plyr)', + matchLevel: FULL, + fullyHighlighted: false, + matchedWords: ['media'], + }, + typeFullMatch: { + value: 'Streaming - (media plyr)', + matchLevel: FULL, + fullyHighlighted: false, + matchedWords: ['streaming', 'media', 'plyr'], + }, + meta: { + name: { + value: 'Nested Amazon name', + matchLevel: NONE, + matchedWords: ['Amazon'], + }, + }, + }, +}; +/* eslint-enable @typescript-eslint/camelcase */ + +describe('reverseHighlight', () => { + test('with default tag name', () => { + expect( + reverseHighlight({ + attribute: 'name', + hit, + }) + ).toMatchInlineSnapshot( + `"Amazon - Fire TV Stick with Alexa Voice Remote - Black"` + ); + }); + + test('with full match', () => { + expect( + reverseHighlight({ + attribute: 'typeFullMatch', + hit, + }) + ).toMatchInlineSnapshot(`"Streaming - (media plyr)"`); + }); + + test('with custom tag name', () => { + expect( + reverseHighlight({ + attribute: 'description', + highlightedTagName: 'em', + hit, + }) + ).toMatchInlineSnapshot( + `"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."` + ); + }); + + test('with custom highlighted class name', () => { + expect( + reverseHighlight({ + attribute: 'description', + cssClasses: { highlighted: '__highlighted class' }, + hit, + }) + ).toMatchInlineSnapshot( + `"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."` + ); + }); + + test('with unknown attribute returns an empty string', () => { + expect( + reverseHighlight({ + attribute: 'wrong-attribute', + hit, + }) + ).toMatchInlineSnapshot(`""`); + }); + + test('with nested attribute', () => { + expect( + reverseHighlight({ + attribute: 'meta.name', + hit, + }) + ).toMatchInlineSnapshot( + `"Nested Amazon name"` + ); + }); + + test('with nested attribute as array', () => { + expect( + reverseHighlight({ + attribute: ['meta', 'name'], + hit, + }) + ).toMatchInlineSnapshot( + `"Nested Amazon name"` + ); + }); + + test('with array attribute', () => { + expect( + reverseHighlight({ + attribute: 'categories.1', + hit, + }) + ).toMatchInlineSnapshot(`"Streaming Media Players"`); + }); + + test('with array attribute as array', () => { + expect( + reverseHighlight({ + attribute: ['categories', '1'], + hit, + }) + ).toMatchInlineSnapshot(`"Streaming Media Players"`); + }); + + test('with non-alphanumeric character with alphanumeric siblings matching highlight', () => { + expect( + reverseHighlight({ + attribute: 'type', + hit, + }) + ).toMatchInlineSnapshot( + `"Streaming - (media plyr)"` + ); + }); + + test('with non-alphanumeric character with different alphanumeric siblings highlight', () => { + expect( + reverseHighlight({ + attribute: 'typeMissingSibling', + hit, + }) + ).toMatchInlineSnapshot( + `"Streaming - (media plyr)"` + ); + }); +}); diff --git a/src/helpers/__tests__/reverseSnippet-test.ts b/src/helpers/__tests__/reverseSnippet-test.ts new file mode 100644 index 0000000000..a686d320a7 --- /dev/null +++ b/src/helpers/__tests__/reverseSnippet-test.ts @@ -0,0 +1,200 @@ +import reverseSnippet from '../reverseSnippet'; + +const NONE = 'none' as const; +const FULL = 'full' as const; + +/* eslint-disable @typescript-eslint/camelcase */ +const hit = { + name: 'Amazon - Fire TV Stick with Alexa Voice Remote - Black', + description: + '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.', + brand: 'Amazon', + categories: ['TV & Home Theater', 'Streaming Media Players'], + hierarchicalCategories: { + lvl0: 'TV & Home Theater', + lvl1: 'TV & Home Theater > Streaming Media Players', + }, + type: 'Streaming media plyr', + price: 39.99, + price_range: '1 - 50', + image: 'https://cdn-demo.algolia.com/bestbuy-0118/5477500_sb.jpg', + url: 'https://api.bestbuy.com/click/-/5477500/pdp', + free_shipping: false, + rating: 4, + popularity: 21469, + objectID: '5477500', + _snippetResult: { + name: { + 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.', + matchLevel: FULL, + fullyHighlighted: false, + matchedWords: ['amazon', 'procesor', '1GB'], + }, + brand: { + value: 'Amazon', + matchLevel: FULL, + fullyHighlighted: true, + matchedWords: ['amazon'], + }, + categories: [ + { + value: 'TV & Home Theater', + matchLevel: NONE, + }, + { + value: 'Streaming Media Players', + matchLevel: NONE, + }, + ], + type: { + value: 'Streaming - (media plyr)', + matchLevel: FULL, + fullyHighlighted: false, + matchedWords: ['streaming', 'media'], + }, + typeMissingSibling: { + value: 'Streaming - (media plyr)', + matchLevel: FULL, + fullyHighlighted: false, + matchedWords: ['media'], + }, + typeFullMatch: { + value: 'Streaming - (media plyr)', + matchLevel: FULL, + fullyHighlighted: false, + matchedWords: ['streaming', 'media', 'plyr'], + }, + meta: { + name: { + value: 'Nested Amazon name', + matchLevel: FULL, + }, + }, + }, +}; +/* eslint-enable @typescript-eslint/camelcase */ + +describe('reverseSnippet', () => { + test('with default tag name', () => { + expect( + reverseSnippet({ + attribute: 'name', + hit, + }) + ).toMatchInlineSnapshot( + `"Amazon - Fire TV Stick with Alexa Voice Remote - Black"` + ); + }); + + test('with full match', () => { + expect( + reverseSnippet({ + attribute: 'typeFullMatch', + hit, + }) + ).toMatchInlineSnapshot(`"Streaming - (media plyr)"`); + }); + + test('with custom tag name', () => { + expect( + reverseSnippet({ + attribute: 'description', + highlightedTagName: 'em', + hit, + }) + ).toMatchInlineSnapshot( + `"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."` + ); + }); + + test('with custom highlighted class name', () => { + expect( + reverseSnippet({ + attribute: 'description', + cssClasses: { highlighted: '__highlighted' }, + hit, + }) + ).toMatchInlineSnapshot( + `"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."` + ); + }); + + test('with unknown attribute returns an empty string', () => { + expect( + reverseSnippet({ + attribute: 'wrong-attribute', + hit, + }) + ).toMatchInlineSnapshot(`""`); + }); + + test('with nested attribute', () => { + expect( + reverseSnippet({ + attribute: 'meta.name', + hit, + }) + ).toMatchInlineSnapshot( + `"Nested Amazon name"` + ); + }); + + test('with nested attribute as array', () => { + expect( + reverseSnippet({ + attribute: ['meta', 'name'], + hit, + }) + ).toMatchInlineSnapshot( + `"Nested Amazon name"` + ); + }); + + test('with array attribute', () => { + expect( + reverseSnippet({ + attribute: 'categories.1', + hit, + }) + ).toMatchInlineSnapshot(`"Streaming Media Players"`); + }); + + test('with array attribute as array', () => { + expect( + reverseSnippet({ + attribute: ['categories', '1'], + hit, + }) + ).toMatchInlineSnapshot(`"Streaming Media Players"`); + }); + + test('with non-alphanumeric character with alphanumeric siblings matching highlight', () => { + expect( + reverseSnippet({ + attribute: 'type', + hit, + }) + ).toMatchInlineSnapshot( + `"Streaming - (media plyr)"` + ); + }); + + test('with non-alphanumeric character with different alphanumeric siblings highlight', () => { + expect( + reverseSnippet({ + attribute: 'typeMissingSibling', + hit, + }) + ).toMatchInlineSnapshot( + `"Streaming - (media plyr)"` + ); + }); +}); diff --git a/src/helpers/highlight.ts b/src/helpers/highlight.ts index cb2ad93321..f77e23704f 100644 --- a/src/helpers/highlight.ts +++ b/src/helpers/highlight.ts @@ -8,9 +8,9 @@ export type HighlightOptions = { attribute: string | string[]; highlightedTagName?: string; hit: Partial; - cssClasses?: { - highlighted?: string; - }; + cssClasses?: Partial<{ + highlighted: string; + }>; }; const suit = component('Highlight'); diff --git a/src/helpers/index.ts b/src/helpers/index.ts index d4df1a4b04..efa2a06e29 100644 --- a/src/helpers/index.ts +++ b/src/helpers/index.ts @@ -1,5 +1,9 @@ export * from './highlight'; +export * from './reverseHighlight'; export * from './snippet'; +export * from './reverseSnippet'; +export { default as reverseHighlight } from './reverseHighlight'; +export { default as reverseSnippet } from './reverseSnippet'; export { default as highlight } from './highlight'; export { default as snippet } from './snippet'; export { default as insights } from './insights'; diff --git a/src/helpers/reverseHighlight.ts b/src/helpers/reverseHighlight.ts new file mode 100644 index 0000000000..3970c63afb --- /dev/null +++ b/src/helpers/reverseHighlight.ts @@ -0,0 +1,51 @@ +import { Hit } from '../types'; +import { + getPropertyByPath, + getHighlightedParts, + reverseHighlightedParts, + concatHighlightedParts, +} from '../lib/utils'; +import { TAG_REPLACEMENT } from '../lib/escape-highlight'; +import { component } from '../lib/suit'; + +export type ReverseHighlightOptions = { + // @MAJOR string should no longer be allowed to be a path, only array can be a path + attribute: string | string[]; + highlightedTagName?: string; + hit: Partial; + cssClasses?: Partial<{ + highlighted: string; + }>; +}; + +const suit = component('ReverseHighlight'); + +export default function reverseHighlight({ + attribute, + highlightedTagName = 'mark', + hit, + cssClasses = {}, +}: ReverseHighlightOptions): string { + const { value: attributeValue = '' } = + getPropertyByPath(hit._highlightResult, attribute) || {}; + + // cx is not used, since it would be bundled as a dependency for Vue & Angular + const className = + suit({ + descendantName: 'highlighted', + }) + (cssClasses.highlighted ? ` ${cssClasses.highlighted}` : ''); + + const reverseHighlightedValue = concatHighlightedParts( + reverseHighlightedParts(getHighlightedParts(attributeValue)) + ); + + return reverseHighlightedValue + .replace( + new RegExp(TAG_REPLACEMENT.highlightPreTag, 'g'), + `<${highlightedTagName} class="${className}">` + ) + .replace( + new RegExp(TAG_REPLACEMENT.highlightPostTag, 'g'), + `` + ); +} diff --git a/src/helpers/reverseSnippet.ts b/src/helpers/reverseSnippet.ts new file mode 100644 index 0000000000..a2f06c22b0 --- /dev/null +++ b/src/helpers/reverseSnippet.ts @@ -0,0 +1,51 @@ +import { Hit } from '../types'; +import { + getPropertyByPath, + getHighlightedParts, + reverseHighlightedParts, + concatHighlightedParts, +} from '../lib/utils'; +import { TAG_REPLACEMENT } from '../lib/escape-highlight'; +import { component } from '../lib/suit'; + +export type ReverseSnippetOptions = { + // @MAJOR string should no longer be allowed to be a path, only array can be a path + attribute: string | string[]; + highlightedTagName?: string; + hit: Partial; + cssClasses?: Partial<{ + highlighted: string; + }>; +}; + +const suit = component('ReverseSnippet'); + +export default function reverseSnippet({ + attribute, + highlightedTagName = 'mark', + hit, + cssClasses = {}, +}: ReverseSnippetOptions): string { + const { value: attributeValue = '' } = + getPropertyByPath(hit._snippetResult, attribute) || {}; + + // cx is not used, since it would be bundled as a dependency for Vue & Angular + const className = + suit({ + descendantName: 'highlighted', + }) + (cssClasses.highlighted ? ` ${cssClasses.highlighted}` : ''); + + const reverseHighlightedValue = concatHighlightedParts( + reverseHighlightedParts(getHighlightedParts(attributeValue)) + ); + + return reverseHighlightedValue + .replace( + new RegExp(TAG_REPLACEMENT.highlightPreTag, 'g'), + `<${highlightedTagName} class="${className}">` + ) + .replace( + new RegExp(TAG_REPLACEMENT.highlightPostTag, 'g'), + `` + ); +} diff --git a/src/index.es.ts b/src/index.es.ts index 74e8f2ebb6..a526fcab2c 100644 --- a/src/index.es.ts +++ b/src/index.es.ts @@ -3,7 +3,9 @@ import InstantSearch from './lib/InstantSearch'; import version from './lib/version'; import { snippet, + reverseSnippet, highlight, + reverseHighlight, insights, getInsightsAnonymousUserToken, } from './helpers'; @@ -14,7 +16,9 @@ const instantsearch = (options: InstantSearchOptions): InstantSearch => instantsearch.version = version; instantsearch.snippet = snippet; +instantsearch.reverseSnippet = reverseSnippet; instantsearch.highlight = highlight; +instantsearch.reverseHighlight = reverseHighlight; instantsearch.insights = insights; instantsearch.getInsightsAnonymousUserToken = getInsightsAnonymousUserToken; instantsearch.createInfiniteHitsSessionStorageCache = createInfiniteHitsSessionStorageCache; diff --git a/src/lib/createHelpers.ts b/src/lib/createHelpers.ts index bb2330250b..01e8e500b7 100644 --- a/src/lib/createHelpers.ts +++ b/src/lib/createHelpers.ts @@ -1,8 +1,12 @@ import { highlight, + reverseHighlight, snippet, + reverseSnippet, HighlightOptions, + ReverseHighlightOptions, SnippetOptions, + ReverseSnippetOptions, insights, } from '../helpers'; import { Hit, InsightsClientMethod, InsightsClientPayload } from '../types'; @@ -12,7 +16,9 @@ type HoganRenderer = (value: any) => string; type HoganHelpers = { formatNumber: (value: number, render: HoganRenderer) => string; highlight: (options: string, render: HoganRenderer) => string; + reverseHighlight: (options: string, render: HoganRenderer) => string; snippet: (options: string, render: HoganRenderer) => string; + reverseSnippet: (options: string, render: HoganRenderer) => string; insights: (options: string, render: HoganRenderer) => string; }; @@ -43,6 +49,25 @@ The highlight helper expects a JSON object of the format: { "attribute": "name", "highlightedTagName": "mark" }`); } }, + reverseHighlight(options, render) { + try { + const reverseHighlightOptions: Omit< + ReverseHighlightOptions, + 'hit' + > = JSON.parse(options); + + return render( + reverseHighlight({ + ...reverseHighlightOptions, + hit: this, + }) + ); + } catch (error) { + throw new Error(` + The reverseHighlight helper expects a JSON object of the format: + { "attribute": "name", "highlightedTagName": "mark" }`); + } + }, snippet(options, render) { try { const snippetOptions: Omit = JSON.parse(options); @@ -59,6 +84,25 @@ The snippet helper expects a JSON object of the format: { "attribute": "name", "highlightedTagName": "mark" }`); } }, + reverseSnippet(options, render) { + try { + const reverseSnippetOptions: Omit< + ReverseSnippetOptions, + 'hit' + > = JSON.parse(options); + + return render( + reverseSnippet({ + ...reverseSnippetOptions, + hit: this, + }) + ); + } catch (error) { + throw new Error(` + The reverseSnippet helper expects a JSON object of the format: + { "attribute": "name", "highlightedTagName": "mark" }`); + } + }, insights(this: Hit, options, render) { try { type InsightsHelperOptions = { diff --git a/src/lib/main.ts b/src/lib/main.ts index f5555209c6..d0b4ef7eae 100644 --- a/src/lib/main.ts +++ b/src/lib/main.ts @@ -38,7 +38,9 @@ instantsearch.widgets = widgets; instantsearch.version = version; instantsearch.createInfiniteHitsSessionStorageCache = createInfiniteHitsSessionStorageCache; instantsearch.highlight = helpers.highlight; +instantsearch.reverseHighlight = helpers.reverseHighlight; instantsearch.snippet = helpers.snippet; +instantsearch.reverseSnippet = helpers.reverseSnippet; instantsearch.insights = helpers.insights; instantsearch.middlewares = middlewares; diff --git a/src/lib/utils/__tests__/concatHighlightedParts-test.ts b/src/lib/utils/__tests__/concatHighlightedParts-test.ts new file mode 100644 index 0000000000..503dda5c10 --- /dev/null +++ b/src/lib/utils/__tests__/concatHighlightedParts-test.ts @@ -0,0 +1,34 @@ +import concatHighlightedParts from '../concatHighlightedParts'; + +describe('concatHighlightedParts', () => { + test('returns a concatenated string from HighlightedParts with a single match', () => { + expect( + concatHighlightedParts([ + { isHighlighted: false, value: 'Amazon' }, + { + isHighlighted: true, + value: ' - Fire HD8 - 8" - Tablet - 16GB - Wi-Fi - Black', + }, + ]) + ).toMatchInlineSnapshot( + `"Amazon - Fire HD8 - 8" - Tablet - 16GB - Wi-Fi - Black"` + ); + }); + + test('returns a concatenated string from HighlightedParts with multiple matches', () => { + expect( + concatHighlightedParts([ + { isHighlighted: false, value: 'Amazon' }, + { + isHighlighted: true, + value: ' - Fire HD8 - 8" - Tablet - 16GB - Wi-', + }, + { isHighlighted: false, value: 'Fi' }, + { isHighlighted: false, value: ' - ' }, + { isHighlighted: false, value: 'Black' }, + ]) + ).toMatchInlineSnapshot( + `"Amazon - Fire HD8 - 8" - Tablet - 16GB - Wi-Fi - Black"` + ); + }); +}); diff --git a/src/lib/utils/__tests__/createSendEventForFacet-test.ts b/src/lib/utils/__tests__/createSendEventForFacet-test.ts index 6d4e2d2428..f2d0f3128a 100644 --- a/src/lib/utils/__tests__/createSendEventForFacet-test.ts +++ b/src/lib/utils/__tests__/createSendEventForFacet-test.ts @@ -86,7 +86,7 @@ If you want to send a custom payload, you can pass one object: sendEvent(customP insightsMethod: 'clickedFilters', payload: { eventName: 'Filter Applied', - filters: ['category:"value"'], + filters: ['category:value'], index: '', }, widgetType: 'ais.customWidget', @@ -104,7 +104,7 @@ If you want to send a custom payload, you can pass one object: sendEvent(customP insightsMethod: 'clickedFilters', payload: { eventName: 'Category Clicked', - filters: ['category:"value"'], + filters: ['category:value'], index: '', }, widgetType: 'ais.customWidget', diff --git a/src/lib/utils/__tests__/getHighlightFromSiblings-test.ts b/src/lib/utils/__tests__/getHighlightFromSiblings-test.ts new file mode 100644 index 0000000000..7eff585be5 --- /dev/null +++ b/src/lib/utils/__tests__/getHighlightFromSiblings-test.ts @@ -0,0 +1,31 @@ +import { HighlightedParts } from '../../../types'; +import getHighlightFromSiblings from '../getHighlightFromSiblings'; + +const oneMatch: HighlightedParts[] = [ + { isHighlighted: true, value: 'Amazon' }, + { + isHighlighted: false, + value: ' - Fire HD8 - 8" - Tablet - 16GB - Wi-Fi - Black', + }, +]; + +const multipleMatches: HighlightedParts[] = [ + { isHighlighted: false, value: 'Amazon' }, + { + isHighlighted: true, + value: ' - Fire HD8 - 8" - Tablet - 16GB - Wi-', + }, + { isHighlighted: false, value: 'Fi' }, + { isHighlighted: false, value: ' - ' }, + { isHighlighted: false, value: 'Black' }, +]; + +describe('getHighlightFromSiblings', () => { + test('returns the isHighlighted value with a missing sibling', () => { + expect(getHighlightFromSiblings(oneMatch, 0)).toEqual(true); + }); + + test('returns the isHighlighted value with both siblings', () => { + expect(getHighlightFromSiblings(multipleMatches, 1)).toEqual(true); + }); +}); diff --git a/src/lib/utils/__tests__/getHighlightedParts-test.ts b/src/lib/utils/__tests__/getHighlightedParts-test.ts new file mode 100644 index 0000000000..4790a6ce8d --- /dev/null +++ b/src/lib/utils/__tests__/getHighlightedParts-test.ts @@ -0,0 +1,39 @@ +import getHighlightedParts from '../getHighlightedParts'; + +describe('getHighlightedParts', () => { + test('returns an HighlightParts array of object from a string with a single match', () => { + expect( + getHighlightedParts( + 'Amazon - Fire HD8 - 8" - Tablet - 16GB - Wi-Fi - Black' + ) + ).toEqual([ + { isHighlighted: true, value: 'Amazon' }, + { + isHighlighted: false, + value: ' - Fire HD8 - 8" - Tablet - 16GB - Wi-Fi - Black', + }, + ]); + }); + + test('returns an HighlightedParts array of object from a string with multiple matches', () => { + expect( + getHighlightedParts( + 'Amazon - Fire HD8 - 8" - Tablet - 16GB - Wi-Fi - Black' + ) + ).toEqual([ + { isHighlighted: true, value: 'Amazon' }, + { + isHighlighted: false, + value: ' - Fire HD8 - 8" - ', + }, + { + isHighlighted: true, + value: 'Tablet', + }, + { + isHighlighted: false, + value: ' - 16GB - Wi-Fi - Black', + }, + ]); + }); +}); diff --git a/src/lib/utils/__tests__/reverseHighlightedParts-test.ts b/src/lib/utils/__tests__/reverseHighlightedParts-test.ts new file mode 100644 index 0000000000..a1979fc0b6 --- /dev/null +++ b/src/lib/utils/__tests__/reverseHighlightedParts-test.ts @@ -0,0 +1,45 @@ +import reverseHighlightedParts from '../reverseHighlightedParts'; + +describe('reverseHighlightedParts', () => { + test('returns reversed HighlightedParts with a single match', () => { + expect( + reverseHighlightedParts([ + { isHighlighted: false, value: 'Amazon' }, + { + isHighlighted: true, + value: ' - Fire HD8 - 8" - Tablet - 16GB - Wi-Fi - Black', + }, + ]) + ).toEqual([ + { isHighlighted: true, value: 'Amazon' }, + { + isHighlighted: false, + value: ' - Fire HD8 - 8" - Tablet - 16GB - Wi-Fi - Black', + }, + ]); + }); + + test('with reversed HighlightedParts with multiple matches', () => { + expect( + reverseHighlightedParts([ + { isHighlighted: false, value: 'Amazon' }, + { + isHighlighted: true, + value: ' - Fire HD8 - 8" - Tablet - 16GB - Wi-', + }, + { isHighlighted: false, value: 'Fi' }, + { isHighlighted: false, value: ' - ' }, + { isHighlighted: false, value: 'Black' }, + ]) + ).toEqual([ + { isHighlighted: true, value: 'Amazon' }, + { + isHighlighted: false, + value: ' - Fire HD8 - 8" - Tablet - 16GB - Wi-', + }, + { isHighlighted: true, value: 'Fi' }, + { isHighlighted: false, value: ' - ' }, + { isHighlighted: true, value: 'Black' }, + ]); + }); +}); diff --git a/src/lib/utils/__tests__/unescape-test.ts b/src/lib/utils/__tests__/unescape-test.ts new file mode 100644 index 0000000000..eb2e2a519f --- /dev/null +++ b/src/lib/utils/__tests__/unescape-test.ts @@ -0,0 +1,25 @@ +import unescape from '../unescape'; + +describe('unescape', () => { + test('should unescape values', () => { + expect(unescape('&<>"'/')).toEqual('&<>"\'/'); + }); + + test('should unescape values in a sentence', () => { + expect(unescape('fred, barney, & pebbles')).toEqual( + 'fred, barney, & pebbles' + ); + }); + + test('should handle strings with nothing to unescape', () => { + expect(unescape('abc')).toEqual('abc'); + }); + + test('should not unescape the "`" character', () => { + expect(unescape('`')).toEqual('`'); + }); + + test('should not unescape the "/" character', () => { + expect(unescape('/')).toEqual('/'); + }); +}); diff --git a/src/lib/utils/concatHighlightedParts.ts b/src/lib/utils/concatHighlightedParts.ts new file mode 100644 index 0000000000..567c1ae99b --- /dev/null +++ b/src/lib/utils/concatHighlightedParts.ts @@ -0,0 +1,14 @@ +import { HighlightedParts } from '../../types'; +import { TAG_REPLACEMENT } from '../escape-highlight'; + +export default function concatHighlightedParts(parts: HighlightedParts[]) { + const { highlightPreTag, highlightPostTag } = TAG_REPLACEMENT; + + return parts + .map(part => + part.isHighlighted + ? highlightPreTag + part.value + highlightPostTag + : part.value + ) + .join(''); +} diff --git a/src/lib/utils/createSendEventForFacet.ts b/src/lib/utils/createSendEventForFacet.ts index 57d60b0836..8807b00f6c 100644 --- a/src/lib/utils/createSendEventForFacet.ts +++ b/src/lib/utils/createSendEventForFacet.ts @@ -40,7 +40,7 @@ export function createSendEventForFacet({ payload: { eventName, index: helper.getIndex(), - filters: [`${attribute}:${JSON.stringify(facetValue)}`], + filters: [`${attribute}:${facetValue}`], }, }); } diff --git a/src/lib/utils/getHighlightFromSiblings.ts b/src/lib/utils/getHighlightFromSiblings.ts new file mode 100644 index 0000000000..ddb64103f2 --- /dev/null +++ b/src/lib/utils/getHighlightFromSiblings.ts @@ -0,0 +1,22 @@ +import unescape from './unescape'; +import { HighlightedParts } from '../../types'; + +const hasAlphanumeric = new RegExp(/\w/i); + +export default function getHighlightFromSiblings( + parts: HighlightedParts[], + i: number +) { + const current = parts[i]; + const isNextHighlighted = parts[i + 1]?.isHighlighted || true; + const isPreviousHighlighted = parts[i - 1]?.isHighlighted || true; + + if ( + !hasAlphanumeric.test(unescape(current.value)) && + isPreviousHighlighted === isNextHighlighted + ) { + return isPreviousHighlighted; + } + + return current.isHighlighted; +} diff --git a/src/lib/utils/getHighlightedParts.ts b/src/lib/utils/getHighlightedParts.ts new file mode 100644 index 0000000000..9ed34fbee9 --- /dev/null +++ b/src/lib/utils/getHighlightedParts.ts @@ -0,0 +1,29 @@ +import { TAG_REPLACEMENT } from '../../lib/escape-highlight'; + +export default function getHighlightedParts(highlightedValue: string) { + const { highlightPostTag, highlightPreTag } = TAG_REPLACEMENT; + + const splitByPreTag = highlightedValue.split(highlightPreTag); + const firstValue = splitByPreTag.shift(); + const elements = !firstValue + ? [] + : [{ value: firstValue, isHighlighted: false }]; + + splitByPreTag.forEach(split => { + const splitByPostTag = split.split(highlightPostTag); + + elements.push({ + value: splitByPostTag[0], + isHighlighted: true, + }); + + if (splitByPostTag[1] !== '') { + elements.push({ + value: splitByPostTag[1], + isHighlighted: false, + }); + } + }); + + return elements; +} diff --git a/src/lib/utils/index.ts b/src/lib/utils/index.ts index 1751cb6e90..5dae975332 100644 --- a/src/lib/utils/index.ts +++ b/src/lib/utils/index.ts @@ -20,6 +20,11 @@ export { default as uniq } from './uniq'; export { default as range } from './range'; export { default as isEqual } from './isEqual'; export { default as escape } from './escape'; +export { default as unescape } from './unescape'; +export { default as concatHighlightedParts } from './concatHighlightedParts'; +export { default as getHighlightedParts } from './getHighlightedParts'; +export { default as getHighlightFromSiblings } from './getHighlightFromSiblings'; +export { default as reverseHighlightedParts } from './reverseHighlightedParts'; export { default as find } from './find'; export { default as findIndex } from './findIndex'; export { default as mergeSearchParameters } from './mergeSearchParameters'; diff --git a/src/lib/utils/reverseHighlightedParts.ts b/src/lib/utils/reverseHighlightedParts.ts new file mode 100644 index 0000000000..e1fe5e5492 --- /dev/null +++ b/src/lib/utils/reverseHighlightedParts.ts @@ -0,0 +1,13 @@ +import { HighlightedParts } from '../../types'; +import getHighlightFromSiblings from './getHighlightFromSiblings'; + +export default function reverseHighlightedParts(parts: HighlightedParts[]) { + if (!parts.some(part => part.isHighlighted)) { + return parts.map(part => ({ ...part, isHighlighted: false })); + } + + return parts.map((part, i) => ({ + ...part, + isHighlighted: !getHighlightFromSiblings(parts, i), + })); +} diff --git a/src/lib/utils/unescape.ts b/src/lib/utils/unescape.ts new file mode 100644 index 0000000000..2054d47a90 --- /dev/null +++ b/src/lib/utils/unescape.ts @@ -0,0 +1,27 @@ +/** + * This implementation is taken from Lodash implementation. + * See: https://github.com/lodash/lodash/blob/4.17.11-npm/unescape.js + */ + +// Used to map HTML entities to characters. +const htmlEscapes = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + ''': "'", +}; + +// Used to match HTML entities and HTML characters. +const regexEscapedHtml = /&(amp|quot|lt|gt|#39);/g; +const regexHasEscapedHtml = RegExp(regexEscapedHtml.source); + +/** + * Converts the HTML entities "&", "<", ">", '"', and "'" in `string` to their + * characters. + */ +export default function unescape(value: string): string { + return value && regexHasEscapedHtml.test(value) + ? value.replace(regexEscapedHtml, character => htmlEscapes[character]) + : value; +} diff --git a/src/lib/version.ts b/src/lib/version.ts index 3fda430543..149e4efe73 100644 --- a/src/lib/version.ts +++ b/src/lib/version.ts @@ -1 +1 @@ -export default '4.9.2'; +export default '4.10.0'; diff --git a/src/types/index.ts b/src/types/index.ts index 21b8ffbba4..c3e15b5d73 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -4,3 +4,4 @@ export * from './widget'; export * from './insights'; export * from './algoliasearch'; export * from './middleware'; +export * from './utils'; diff --git a/src/types/utils.ts b/src/types/utils.ts new file mode 100644 index 0000000000..07615a81f0 --- /dev/null +++ b/src/types/utils.ts @@ -0,0 +1,4 @@ +export type HighlightedParts = { + value: string; + isHighlighted: boolean; +}; diff --git a/src/widgets/configure/configure.ts b/src/widgets/configure/configure.ts index 752b31633a..45d120b046 100644 --- a/src/widgets/configure/configure.ts +++ b/src/widgets/configure/configure.ts @@ -1,4 +1,3 @@ -import { PlainSearchParameters } from 'algoliasearch-helper'; import connectConfigure, { ConfigureRendererOptions, ConfigureConnectorParams, @@ -10,14 +9,14 @@ import { noop } from '../../lib/utils'; * A list of [search parameters](https://www.algolia.com/doc/api-reference/search-api-parameters/) * to enable when the widget mounts. */ -export type ConfigureWidgetParams = PlainSearchParameters; +export type ConfigureWidgetParams = ConfigureConnectorParams['searchParameters']; export type ConfigureWidget = ( - widgetParams: ConfigureConnectorParams['searchParameters'] + widgetParams: ConfigureWidgetParams ) => Widget<{ renderState: WidgetRenderState< ConfigureRendererOptions, - ConfigureWidgetParams + ConfigureConnectorParams >; }>; diff --git a/src/widgets/index/__tests__/index-test.ts b/src/widgets/index/__tests__/index-test.ts index 49f81ba760..88c070e91a 100644 --- a/src/widgets/index/__tests__/index-test.ts +++ b/src/widgets/index/__tests__/index-test.ts @@ -14,6 +14,7 @@ import { runAllMicroTasks } from '../../../../test/utils/runAllMicroTasks'; import { Widget, InstantSearch } from '../../../types'; import index from '../index'; import { warning } from '../../../lib/utils'; +import { refinementList } from '../..'; describe('index', () => { const createSearchBox = (args: Partial = {}): Widget => @@ -651,6 +652,236 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/index-widge }); }); + describe('createURL', () => { + it('default url returns #', () => { + const instance = index({ indexName: 'indexName' }); + const searchBox = createSearchBox(); + const pagination = createPagination(); + + instance.addWidgets([searchBox, pagination]); + + instance.init(createInitOptions()); + + expect(instance.createURL(new SearchParameters())).toEqual('#'); + }); + + it('calls the createURL of routing', () => { + const instance = index({ indexName: 'indexName' }); + const searchBox = createSearchBox(); + const pagination = createPagination(); + + instance.addWidgets([searchBox, pagination]); + + instance.init( + createInitOptions({ + instantSearchInstance: createInstantSearch({ + // @ts-ignore + _createURL(routeState) { + return routeState; + }, + }), + }) + ); + + expect(instance.createURL(new SearchParameters())).toEqual({ + indexName: {}, + }); + }); + + it('create URLs with custom helper state', () => { + const instance = index({ indexName: 'indexName' }); + const searchBox = createSearchBox(); + const pagination = createPagination(); + + instance.addWidgets([searchBox, pagination]); + + instance.init( + createInitOptions({ + instantSearchInstance: createInstantSearch({ + // @ts-ignore + _createURL(routeState) { + return routeState; + }, + }), + }) + ); + + expect(instance.createURL(new SearchParameters({ page: 100 }))).toEqual({ + indexName: { page: 100 }, + }); + }); + + it('create URLs with non-namesake helper state', () => { + const instance = index({ indexName: 'indexName' }); + const searchBox = createSearchBox(); + const pagination = createPagination(); + + const container = document.createElement('div'); + document.body.append(container); + + instance.addWidgets([ + searchBox, + pagination, + refinementList({ container, attribute: 'doggies' }), + ]); + + instance.init( + createInitOptions({ + instantSearchInstance: createInstantSearch({ + // @ts-ignore + _createURL(routeState) { + return routeState; + }, + }), + }) + ); + + expect( + instance.createURL( + new SearchParameters({ + disjunctiveFacets: ['doggies'], + disjunctiveFacetsRefinements: { doggies: ['zap'] }, + }) + ) + ).toEqual({ + indexName: { refinementList: { doggies: ['zap'] } }, + }); + }); + }); + + describe('getScopedResults', () => { + it('gets deep results', async () => { + const level0 = index({ indexName: 'level0IndexName' }); + const level1 = index({ indexName: 'level1IndexName' }); + const level2 = index({ indexName: 'level2IndexName' }); + const level21 = index({ indexName: 'level21IndexName' }); + const level22 = index({ indexName: 'level22IndexName' }); + const level221 = index({ indexName: 'level221IndexName' }); + const level3 = index({ indexName: 'level3IndexName' }); + const searchBoxLevel0 = createSearchBox(); + const searchBoxLevel1 = createSearchBox(); + const searchBoxLevel21 = createSearchBox(); + + level0.addWidgets([ + searchBoxLevel0, + level1.addWidgets([searchBoxLevel1]), + level2.addWidgets([ + createSearchBox(), + level21.addWidgets([searchBoxLevel21]), + level22.addWidgets([ + createSearchBox(), + level221.addWidgets([createSearchBox()]), + ]), + ]), + level3.addWidgets([createSearchBox()]), + ]); + + level0.init(createInitOptions({ parent: null })); + + // Simulate a call to search from a widget - this step is required otherwise + // the DerivedHelper does not contain the results. The `lastResults` attribute + // is set once the `result` event is emitted. + level0.getHelper()!.search(); + + await runAllMicroTasks(); + + expect(level1.getScopedResults()).toEqual([ + // Root index + { + indexId: 'level1IndexName', + results: expect.any(algoliasearchHelper.SearchResults), + helper: level1.getHelper(), + }, + // Siblings and children + { + indexId: 'level2IndexName', + results: expect.any(algoliasearchHelper.SearchResults), + helper: level2.getHelper(), + }, + { + indexId: 'level21IndexName', + results: expect.any(algoliasearchHelper.SearchResults), + helper: level21.getHelper(), + }, + { + indexId: 'level22IndexName', + results: expect.any(algoliasearchHelper.SearchResults), + helper: level22.getHelper(), + }, + { + indexId: 'level221IndexName', + results: expect.any(algoliasearchHelper.SearchResults), + helper: level221.getHelper(), + }, + { + indexId: 'level3IndexName', + results: expect.any(algoliasearchHelper.SearchResults), + helper: level3.getHelper(), + }, + ]); + + expect(level21.getScopedResults()).toEqual([ + // Root index + { + indexId: 'level21IndexName', + results: expect.any(algoliasearchHelper.SearchResults), + helper: level21.getHelper(), + }, + // Siblings and children + { + indexId: 'level22IndexName', + results: expect.any(algoliasearchHelper.SearchResults), + helper: level22.getHelper(), + }, + { + indexId: 'level221IndexName', + results: expect.any(algoliasearchHelper.SearchResults), + helper: level221.getHelper(), + }, + ]); + + expect(level0.getScopedResults()).toEqual([ + // Root index + { + indexId: 'level0IndexName', + results: expect.any(algoliasearchHelper.SearchResults), + helper: level0.getHelper(), + }, + // Siblings and children + { + indexId: 'level1IndexName', + results: expect.any(algoliasearchHelper.SearchResults), + helper: level1.getHelper(), + }, + { + indexId: 'level2IndexName', + results: expect.any(algoliasearchHelper.SearchResults), + helper: level2.getHelper(), + }, + { + indexId: 'level21IndexName', + results: expect.any(algoliasearchHelper.SearchResults), + helper: level21.getHelper(), + }, + { + indexId: 'level22IndexName', + results: expect.any(algoliasearchHelper.SearchResults), + helper: level22.getHelper(), + }, + { + indexId: 'level221IndexName', + results: expect.any(algoliasearchHelper.SearchResults), + helper: level221.getHelper(), + }, + { + indexId: 'level3IndexName', + results: expect.any(algoliasearchHelper.SearchResults), + helper: level3.getHelper(), + }, + ]); + }); + }); + describe('init', () => { it('forwards the `search` call to the main instance', () => { const instance = index({ indexName: 'indexName' }); @@ -2579,5 +2810,29 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/index-widge '[InstantSearch.js]: The `getWidgetState` method is renamed `getWidgetUiState` and will no longer exist under that name in InstantSearch.js 5.x. Please use `getWidgetUiState` instead.' ); }); + + test('does not warn for index itself', () => { + warning.cache = {}; + + const instance = index({ indexName: 'indexName' }); + const searchClient = createSearchClient(); + const mainHelper = algoliasearchHelper(searchClient, '', {}); + const instantSearchInstance = createInstantSearch({ + mainHelper, + }); + + instance.addWidgets([index({ indexName: 'other' })]); + + expect(() => { + instance.init( + createInitOptions({ + instantSearchInstance, + parent: null, + }) + ); + }).not.toWarnDev( + '[InstantSearch.js]: The `getWidgetState` method is renamed `getWidgetUiState` and will no longer exist under that name in InstantSearch.js 5.x. Please use `getWidgetUiState` instead.' + ); + }); }); }); diff --git a/src/widgets/index/index.ts b/src/widgets/index/index.ts index c74ca9fddf..2022793a8f 100644 --- a/src/widgets/index/index.ts +++ b/src/widgets/index/index.ts @@ -51,10 +51,14 @@ export type Index = Widget & { getIndexId(): string; getHelper(): Helper | null; getResults(): SearchResults | null; + getScopedResults(): ScopedResult[]; getParent(): Index | null; getWidgets(): Widget[]; + createURL(state: SearchParameters): string; + addWidgets(widgets: Widget[]): Index; removeWidgets(widgets: Widget[]): Index; + init(options: IndexInitOptions): void; render(options: IndexRenderOptions): void; dispose(): void; @@ -103,7 +107,7 @@ function privateHelperSetState( } } -function getLocalWidgetsState( +function getLocalWidgetsUiState( widgets: Widget[], widgetStateOptions: WidgetUiStateOptions, initialUiState: IndexUiState = {} @@ -174,14 +178,6 @@ function resolveScopedResultsFromWidgets(widgets: Widget[]): ScopedResult[] { }, []); } -function resolveScopedResultsFromIndex(widget: Index): ScopedResult[] { - const widgetParent = widget.getParent(); - // If the widget is the root, we consider itself as the only sibling. - const widgetSiblings = widgetParent ? widgetParent.getWidgets() : [widget]; - - return resolveScopedResultsFromWidgets(widgetSiblings); -} - const index = (props: IndexProps): Index => { if (props === undefined || props.indexName === undefined) { throw new Error(withUsage('The `indexName` option is required.')); @@ -196,14 +192,6 @@ const index = (props: IndexProps): Index => { let helper: Helper | null = null; let derivedHelper: DerivedHelper | null = null; - const createURL = (nextState: SearchParameters) => - localInstantSearchInstance!._createURL!({ - [indexId]: getLocalWidgetsState(localWidgets, { - searchParameters: nextState, - helper: helper!, - }), - }); - return { $$type: 'ais.index', @@ -223,10 +211,28 @@ const index = (props: IndexProps): Index => { return derivedHelper && derivedHelper.lastResults; }, + getScopedResults() { + const widgetParent = this.getParent(); + + // If the widget is the root, we consider itself as the only sibling. + const widgetSiblings = widgetParent ? widgetParent.getWidgets() : [this]; + + return resolveScopedResultsFromWidgets(widgetSiblings); + }, + getParent() { return localParent; }, + createURL(nextState: SearchParameters) { + return localInstantSearchInstance!._createURL!({ + [indexId]: getLocalWidgetsUiState(localWidgets, { + searchParameters: nextState, + helper: helper!, + }), + }); + }, + getWidgets() { return localWidgets; }, @@ -278,7 +284,7 @@ const index = (props: IndexProps): Index => { state: helper!.state, renderState: localInstantSearchInstance!.renderState, templatesConfig: localInstantSearchInstance!.templatesConfig, - createURL, + createURL: this.createURL, scopedResults: [], searchMetadata: { isSearchStalled: localInstantSearchInstance!._isSearchStalled, @@ -304,7 +310,7 @@ const index = (props: IndexProps): Index => { state: helper!.state, renderState: localInstantSearchInstance!.renderState, templatesConfig: localInstantSearchInstance!.templatesConfig, - createURL, + createURL: this.createURL, scopedResults: [], searchMetadata: { isSearchStalled: localInstantSearchInstance!._isSearchStalled, @@ -344,7 +350,7 @@ const index = (props: IndexProps): Index => { return next || state; }, helper!.state); - localUiState = getLocalWidgetsState(localWidgets, { + localUiState = getLocalWidgetsUiState(localWidgets, { searchParameters: nextState, helper: helper!, }); @@ -484,7 +490,7 @@ const index = (props: IndexProps): Index => { state: helper!.state, renderState: instantSearchInstance.renderState, templatesConfig: instantSearchInstance.templatesConfig, - createURL, + createURL: this.createURL, scopedResults: [], searchMetadata: { isSearchStalled: instantSearchInstance._isSearchStalled, @@ -502,7 +508,9 @@ const index = (props: IndexProps): Index => { localWidgets.forEach(widget => { warning( - !widget.getWidgetState, + // if it has NO getWidgetState or if it has getWidgetUiState, we don't warn + // aka we warn if there's _only_ getWidgetState + !widget.getWidgetState || Boolean(widget.getWidgetUiState), 'The `getWidgetState` method is renamed `getWidgetUiState` and will no longer exist under that name in InstantSearch.js 5.x. Please use `getWidgetUiState` instead.' ); @@ -515,7 +523,7 @@ const index = (props: IndexProps): Index => { state: helper!.state, renderState: instantSearchInstance.renderState, templatesConfig: instantSearchInstance.templatesConfig, - createURL, + createURL: this.createURL, scopedResults: [], searchMetadata: { isSearchStalled: instantSearchInstance._isSearchStalled, @@ -536,7 +544,7 @@ const index = (props: IndexProps): Index => { // @ts-ignore _uiState comes from privateHelperSetState and thus isn't typed on the helper event const _uiState = event._uiState; - localUiState = getLocalWidgetsState( + localUiState = getLocalWidgetsUiState( localWidgets, { searchParameters: state, @@ -567,11 +575,11 @@ const index = (props: IndexProps): Index => { parent: this, instantSearchInstance, results: this.getResults()!, - scopedResults: resolveScopedResultsFromIndex(this), + scopedResults: this.getScopedResults(), state: this.getResults()!._state, renderState: instantSearchInstance.renderState, templatesConfig: instantSearchInstance.templatesConfig, - createURL, + createURL: this.createURL, searchMetadata: { isSearchStalled: instantSearchInstance._isSearchStalled, }, @@ -600,11 +608,11 @@ const index = (props: IndexProps): Index => { parent: this, instantSearchInstance, results: this.getResults()!, - scopedResults: resolveScopedResultsFromIndex(this), + scopedResults: this.getScopedResults(), state: this.getResults()!._state, renderState: instantSearchInstance.renderState, templatesConfig: instantSearchInstance.templatesConfig, - createURL, + createURL: this.createURL, searchMetadata: { isSearchStalled: instantSearchInstance._isSearchStalled, }, @@ -665,7 +673,7 @@ const index = (props: IndexProps): Index => { }, refreshUiState() { - localUiState = getLocalWidgetsState(localWidgets, { + localUiState = getLocalWidgetsUiState(localWidgets, { searchParameters: this.getHelper()!.state, helper: this.getHelper()!, }); diff --git a/stories/hits.stories.js b/stories/hits.stories.js index 95a1627ab6..3e4517a967 100644 --- a/stories/hits.stories.js +++ b/stories/hits.stories.js @@ -58,6 +58,38 @@ storiesOf('Results/Hits', module) ]); }) ) + .add( + 'with reverseHighlight function', + withHits(({ search, container, instantsearch }) => { + search.addWidgets([ + hits({ + container, + templates: { + item(hit) { + return instantsearch.reverseHighlight({ + attribute: 'name', + hit, + }); + }, + }, + }), + ]); + }) + ) + .add( + 'with reverseHighlight helper', + withHits(({ search, container }) => { + search.addWidgets([ + hits({ + container, + templates: { + item: + '{{#helpers.reverseHighlight}}{ "attribute": "name" }{{/helpers.reverseHighlight}}', + }, + }), + ]); + }) + ) .add( 'with snippet function', withHits(({ search, container, instantsearch }) => { @@ -109,6 +141,57 @@ storiesOf('Results/Hits', module) ]); }) ) + .add( + 'with reverseSnippet function', + withHits(({ search, container, instantsearch }) => { + search.addWidgets([ + configure({ + attributesToSnippet: ['name', 'description'], + }), + ]); + + search.addWidgets([ + hits({ + container, + templates: { + item(hit) { + return ` +

${instantsearch.reverseSnippet({ + attribute: 'name', + hit, + })}

+

${instantsearch.reverseSnippet({ + attribute: 'description', + hit, + })}

+ `; + }, + }, + }), + ]); + }) + ) + .add( + 'with reverseSnippet helper', + withHits(({ search, container }) => { + search.addWidgets([ + configure({ + attributesToSnippet: ['name', 'description'], + }), + ]); + + search.addWidgets([ + hits({ + container, + templates: { + item: ` +

{{#helpers.reverseSnippet}}{ "attribute": "name", "highlightedTagName": "mark" }{{/helpers.reverseSnippet}}

+

{{#helpers.reverseSnippet}}{ "attribute": "description", "highlightedTagName": "mark" }{{/helpers.reverseSnippet}}

`, + }, + }), + ]); + }) + ) .add( 'with insights function', withHits(