From 8e93c6af1e8b1ebff3ef15d097ca2265afd9c4d2 Mon Sep 17 00:00:00 2001 From: Marie-Laure Sin Date: Tue, 19 Dec 2017 08:48:15 +0100 Subject: [PATCH] feat(Highlight): support array of strings (#715) * feat(highlightArray): plug new index to a story * feat(highlightArray): add test for highlight * feat(highlightArray): visually highlight the element in arrays * feat(highlightArray): wrap separator with span * feat(highlightArray): modify story * feat(highlightArray): move tagName logic to Highlighter component * feat(highlightArray): manage className with cx * feat(highlightArray): change className to render style * feat(highlightArray): move back to tagName * feat(highlightArray): test Highlighter * feat(highlightArray): merge two stories in Storybook * feat(highlightArray): updating Highlighting results guide * feat(highlightArray): update Highlight and Snippet Widgets documentation * feat(highlightArray): fix linting * feat(highlightArray): fix linting error * feat(highlightArray): updates to address reviews on PR * feat(highlightArray): add function to generate key for iterables * feat(highlightArray): update documentation after feedback on PR --- docgen/src/guide/Highlighting_results.md | 6 +- .../src/components/Highlight.js | 2 + .../src/components/Highlighter.js | 86 +++- .../src/components/Highlighter.test.js | 274 +++++++++---- .../src/components/Snippet.js | 2 + .../__snapshots__/Highlighter.test.js.snap | 367 ++++++++++++++++-- .../src/connectors/connectHighlight.js | 2 +- .../react-instantsearch/src/core/highlight.js | 24 +- .../src/core/highlight.test.js | 30 ++ .../src/widgets/Highlight.js | 12 +- .../src/widgets/Snippet.js | 8 +- stories/Highlight.stories.js | 58 ++- 12 files changed, 735 insertions(+), 136 deletions(-) diff --git a/docgen/src/guide/Highlighting_results.md b/docgen/src/guide/Highlighting_results.md index 8ce5bca82d..839f826647 100644 --- a/docgen/src/guide/Highlighting_results.md +++ b/docgen/src/guide/Highlighting_results.md @@ -20,8 +20,8 @@ like most of its features it comes in two flavors, depending on your use case: ## <Highlight> and <Snippet> widgets Highlighting is based on the results and you will need to make a custom Hit in order -to use the Highlighter. The Highlight and the Snippet widgets takes two props: - - attributeName: the path to the highlighted attribute +to use the Highlighter. The Highlight and the Snippet widgets take two props: + - attributeName: the path to the highlighted attribute of the hit (which can be either a string or an array of strings) - hit: a single result object **Notes:** @@ -69,7 +69,7 @@ from the results. This function takes a single parameter object with three properties: - attributeName: the highlighted attribute name - hit: a single result object - - highlightProperty: the path to the structure containing the highlighted attribute. The value is either `_highlightResult` or `_snippetResult` depending if you want to make an Highlight or Snippet widget. + - highlightProperty: the path to the structure containing the highlighted attribute. The value is either `_highlightResult` or `_snippetResult` depending on whether you want to make a Highlight or a Snippet widget. Those parameters are taken from the context in which the the custom component is used, therefore it's reasonable to have them as props. diff --git a/packages/react-instantsearch/src/components/Highlight.js b/packages/react-instantsearch/src/components/Highlight.js index 14a39fce4a..e0004f0c51 100644 --- a/packages/react-instantsearch/src/components/Highlight.js +++ b/packages/react-instantsearch/src/components/Highlight.js @@ -11,4 +11,6 @@ Highlight.propTypes = { attributeName: PropTypes.string.isRequired, highlight: PropTypes.func.isRequired, tagName: PropTypes.string, + nonHighlightedTagName: PropTypes.string, + separatorComponent: PropTypes.node, }; diff --git a/packages/react-instantsearch/src/components/Highlighter.js b/packages/react-instantsearch/src/components/Highlighter.js index b74eae29c7..5c0aadf148 100644 --- a/packages/react-instantsearch/src/components/Highlighter.js +++ b/packages/react-instantsearch/src/components/Highlighter.js @@ -1,5 +1,30 @@ import PropTypes from 'prop-types'; import React from 'react'; +import classNames from './classNames'; + +const cx = classNames('Highlight'); + +function generateKey(i, value) { + return `split-${i}-${value}`; +} + +export const Highlight = ({ + value, + highlightedTagName, + isHighlighted, + nonHighlightedTagName, +}) => { + const TagName = isHighlighted ? highlightedTagName : nonHighlightedTagName; + const className = isHighlighted ? 'highlighted' : 'nonHighlighted'; + return {value}; +}; + +Highlight.propTypes = { + value: PropTypes.string.isRequired, + isHighlighted: PropTypes.bool.isRequired, + highlightedTagName: PropTypes.string.isRequired, + nonHighlightedTagName: PropTypes.string.isRequired, +}; export default function Highlighter({ hit, @@ -7,29 +32,48 @@ export default function Highlighter({ highlight, highlightProperty, tagName, + nonHighlightedTagName, + separator, }) { const parsedHighlightedValue = highlight({ hit, attributeName, highlightProperty, }); - const reactHighlighted = parsedHighlightedValue.map((v, i) => { - const key = `split-${i}-${v.value}`; - if (!v.isHighlighted) { - return ( - - {v.value} - - ); - } - const HighlightedTag = tagName ? tagName : 'em'; - return ( - - {v.value} - - ); - }); - return {reactHighlighted}; + + return ( + + {parsedHighlightedValue.map((item, i) => { + if (Array.isArray(item)) { + const isLast = i === parsedHighlightedValue.length - 1; + return ( + + {item.map((element, index) => ( + + ))} + {!isLast && {separator}} + + ); + } + + return ( + + ); + })} + + ); } Highlighter.propTypes = { @@ -38,4 +82,12 @@ Highlighter.propTypes = { highlight: PropTypes.func.isRequired, highlightProperty: PropTypes.string.isRequired, tagName: PropTypes.string, + nonHighlightedTagName: PropTypes.string, + separator: PropTypes.node, +}; + +Highlighter.defaultProps = { + tagName: 'em', + nonHighlightedTagName: 'span', + separator: ', ', }; diff --git a/packages/react-instantsearch/src/components/Highlighter.test.js b/packages/react-instantsearch/src/components/Highlighter.test.js index 652b5aa142..7c2449398e 100644 --- a/packages/react-instantsearch/src/components/Highlighter.test.js +++ b/packages/react-instantsearch/src/components/Highlighter.test.js @@ -1,88 +1,224 @@ /* eslint-env jest, jasmine */ import React from 'react'; -import renderer from 'react-test-renderer'; - -import Highlighter from './Highlighter'; +import Enzyme, { shallow } from 'enzyme'; +import Adapter from 'enzyme-adapter-react-16'; +import Highlighter, { Highlight } from './Highlighter'; import parseAlgoliaHit from '../core/highlight'; -describe('Highlighter', () => { - it('parses an highlighted attribute of hit object', () => { - const hitFromAPI = { - objectID: 0, - deep: { attribute: { value: 'awesome highlighted hit!' } }, - _highlightProperty: { - deep: { - attribute: { - value: { - value: - 'awesome highlighted hit!', - fullyHighlighted: true, - matchLevel: 'full', - matchedWords: [''], - }, +Enzyme.configure({ adapter: new Adapter() }); + +describe('Highlighter - Highlight', () => { + const defaultProps = { + value: 'test', + highlightedTagName: 'em', + isHighlighted: false, + nonHighlightedTagName: 'div', + }; + + it('renders a highlight', () => { + const props = { + ...defaultProps, + isHighlighted: true, + }; + + const wrapper = shallow(); + + expect(wrapper).toMatchSnapshot(); + }); + + it('renders a nonhighlight', () => { + const props = { + ...defaultProps, + }; + + const wrapper = shallow(); + + expect(wrapper).toMatchSnapshot(); + }); +}); + +describe('Highlighter - simple', () => { + const hitFromAPI = { + objectID: 3, + title: 'Apple', + _highlight: { + title: { + value: 'Apple', + }, + }, + }; + + const highlight = ({ hit, attributeName, highlightProperty }) => + parseAlgoliaHit({ + preTag: '', + postTag: '', + attributeName, + hit, + highlightProperty, + }); + + const defaultProps = { + hit: hitFromAPI, + attributeName: 'title', + highlightProperty: '_highlight', + highlight, + }; + + it('renders a highlighted value', () => { + const props = { + ...defaultProps, + }; + + const wrapper = shallow(); + + expect(wrapper).toMatchSnapshot(); + }); + + it('renders a non highlighted value', () => { + const props = { + ...defaultProps, + hit: { + objectID: 3, + title: 'Apple', + _highlight: { + title: { + value: 'Apple', }, }, }, }; - const highlight = ({ hit, attributeName, highlightProperty }) => - parseAlgoliaHit({ - preTag: '', - postTag: '', - attributeName, - hit, - highlightProperty, - }); - - const tree = renderer.create( - - ); - expect(tree.toJSON()).toMatchSnapshot(); + const wrapper = shallow(); + + expect(wrapper).toMatchSnapshot(); + }); + + it('renders a highlighted value with a custom tagName', () => { + const props = { + ...defaultProps, + tagName: 'strong', + }; + + const wrapper = shallow(); + + expect(wrapper).toMatchSnapshot(); + }); + + it('renders a highlighted value with a custom nonHighlightedTagName', () => { + const props = { + ...defaultProps, + nonHighlightedTagName: 'p', + }; + + const wrapper = shallow(); + + expect(wrapper).toMatchSnapshot(); }); +}); + +describe('Highlighter - multi', () => { + const hitFromAPI = { + objectID: 3, + titles: ['Apple', 'Samsung', 'Philips'], + _highlight: { + titles: [ + { + value: 'Apple', + }, + { + value: 'Samsung', + }, + { + value: 'Philips', + }, + ], + }, + }; + + const highlight = ({ hit, attributeName, highlightProperty }) => + parseAlgoliaHit({ + preTag: '', + postTag: '', + attributeName, + hit, + highlightProperty, + }); + + const defaultProps = { + hit: hitFromAPI, + attributeName: 'titles', + highlightProperty: '_highlight', + highlight, + }; - it('renders a hit with a custom tag correctly', () => { - const hitFromAPI = { - objectID: 0, - deep: { attribute: { value: 'awesome highlighted hit!' } }, - _highlightProperty: { - deep: { - attribute: { - value: { - value: - 'awesome highlighted hit!', - fullyHighlighted: true, - matchLevel: 'full', - matchedWords: [''], + it('renders a highlighted value', () => { + const props = { + ...defaultProps, + }; + + const wrapper = shallow(); + + expect(wrapper).toMatchSnapshot(); + }); + + it('renders a non highlighted value', () => { + const props = { + ...defaultProps, + hit: { + objectID: 3, + titles: ['Apple', 'Samsung', 'Philips'], + _highlight: { + titles: [ + { + value: 'Apple', }, - }, + { + value: 'Samsung', + }, + { + value: 'Philips', + }, + ], }, }, }; - const highlight = ({ hit, attributeName, highlightProperty }) => - parseAlgoliaHit({ - preTag: '', - postTag: '', - attributeName, - hit, - highlightProperty, - }); - - const tree = renderer.create( - - ); - expect(tree.toJSON()).toMatchSnapshot(); + const wrapper = shallow(); + + expect(wrapper).toMatchSnapshot(); + }); + + it('renders a highlighted value with a custom tagName', () => { + const props = { + ...defaultProps, + tagName: 'strong', + }; + + const wrapper = shallow(); + + expect(wrapper).toMatchSnapshot(); + }); + + it('renders a highlighted value with a custom nonHighlightedTagName', () => { + const props = { + ...defaultProps, + nonHighlightedTagName: 'p', + }; + + const wrapper = shallow(); + + expect(wrapper).toMatchSnapshot(); + }); + + it('renders a highlighted value with a custom separator', () => { + const props = { + ...defaultProps, + separator: '-', + }; + + const wrapper = shallow(); + + expect(wrapper).toMatchSnapshot(); }); }); diff --git a/packages/react-instantsearch/src/components/Snippet.js b/packages/react-instantsearch/src/components/Snippet.js index 748f308cbc..ad21c832b8 100644 --- a/packages/react-instantsearch/src/components/Snippet.js +++ b/packages/react-instantsearch/src/components/Snippet.js @@ -12,4 +12,6 @@ Snippet.propTypes = { attributeName: PropTypes.string.isRequired, highlight: PropTypes.func.isRequired, tagName: PropTypes.string, + nonHighlightedTagName: PropTypes.string, + separatorComponent: PropTypes.node, }; diff --git a/packages/react-instantsearch/src/components/__snapshots__/Highlighter.test.js.snap b/packages/react-instantsearch/src/components/__snapshots__/Highlighter.test.js.snap index d41003dc66..106f5bb921 100644 --- a/packages/react-instantsearch/src/components/__snapshots__/Highlighter.test.js.snap +++ b/packages/react-instantsearch/src/components/__snapshots__/Highlighter.test.js.snap @@ -1,65 +1,372 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`Highlighter parses an highlighted attribute of hit object 1`] = ` +exports[`Highlighter - Highlight renders a highlight 1`] = ` + + test + +`; + +exports[`Highlighter - Highlight renders a nonhighlight 1`] = ` +
+ test +
+`; + +exports[`Highlighter - multi renders a highlighted value 1`] = ` + + + , + + + - awesome + + + + , + - - hi - + + + +`; + +exports[`Highlighter - multi renders a highlighted value with a custom nonHighlightedTagName 1`] = ` + - ghlighted + + + , + - - hi - + + + + , + + - t! + `; -exports[`Highlighter renders a hit with a custom tag correctly 1`] = ` +exports[`Highlighter - multi renders a highlighted value with a custom separator 1`] = ` - awesome + + + - + - + + + + - + + + - hi - + + + +`; + +exports[`Highlighter - multi renders a highlighted value with a custom tagName 1`] = ` + - ghlighted + + + , + - - hi - + + + + , + + - t! + `; + +exports[`Highlighter - multi renders a non highlighted value 1`] = ` + + + + + , + + + + + + , + + + + + + +`; + +exports[`Highlighter - simple renders a highlighted value 1`] = ` + + + + +`; + +exports[`Highlighter - simple renders a highlighted value with a custom nonHighlightedTagName 1`] = ` + + + + +`; + +exports[`Highlighter - simple renders a highlighted value with a custom tagName 1`] = ` + + + + +`; + +exports[`Highlighter - simple renders a non highlighted value 1`] = ` + + + +`; diff --git a/packages/react-instantsearch/src/connectors/connectHighlight.js b/packages/react-instantsearch/src/connectors/connectHighlight.js index 7f409c0a8e..7bfe3ecb42 100644 --- a/packages/react-instantsearch/src/connectors/connectHighlight.js +++ b/packages/react-instantsearch/src/connectors/connectHighlight.js @@ -19,7 +19,7 @@ const highlight = ({ attributeName, hit, highlightProperty }) => * @name connectHighlight * @kind connector * @category connector - * @providedPropType {function} highlight - the function to retrieve and parse an attribute from a hit. It takes a configuration object with 3 attribute: `highlightProperty` which is the property that contains the highlight structure from the records, `attributeName` which is the name of the attribute to look for and `hit` which is the hit from Algolia. It returns an array of object `{value: string, isHighlighted: boolean}`. + * @providedPropType {function} highlight - function to retrieve and parse an attribute from a hit. It takes a configuration object with 3 attributes: `highlightProperty` which is the property that contains the highlight structure from the records, `attributeName` which is the name of the attribute (it can be either a string or an array of strings) to look for and `hit` which is the hit from Algolia. It returns an array of objects `{value: string, isHighlighted: boolean}. If the element that corresponds to the attributeName is an array of strings, it will return a nested array of objects. * @example * import React from 'react'; * import { connectHighlight } from 'react-instantsearch/connectors'; diff --git a/packages/react-instantsearch/src/core/highlight.js b/packages/react-instantsearch/src/core/highlight.js index 5a18e3f65a..8fd87e2167 100644 --- a/packages/react-instantsearch/src/core/highlight.js +++ b/packages/react-instantsearch/src/core/highlight.js @@ -14,7 +14,7 @@ import { get } from 'lodash'; * @param {string} highlightProperty - the property that contains the highlight structure in the results * @param {string} attributeName - the highlighted attribute to look for * @param {object} hit - the actual hit returned by Algolia. - * @return {object[]} - An array of {value: string, isDefined: boolean}. + * @return {object[]} - An array of {value: string, isHighlighted: boolean}. */ export default function parseAlgoliaHit({ preTag = '', @@ -25,10 +25,23 @@ export default function parseAlgoliaHit({ }) { if (!hit) throw new Error('`hit`, the matching record, must be provided'); - const highlightObject = get(hit[highlightProperty], attributeName); - const highlightedValue = !highlightObject ? '' : highlightObject.value; + const highlightObject = get(hit[highlightProperty], attributeName, {}); + + if (Array.isArray(highlightObject)) { + return highlightObject.map(item => + parseHighlightedAttribute({ + preTag, + postTag, + highlightedValue: item.value, + }) + ); + } - return parseHighlightedAttribute({ preTag, postTag, highlightedValue }); + return parseHighlightedAttribute({ + preTag, + postTag, + highlightedValue: highlightObject.value, + }); } /** @@ -40,7 +53,7 @@ export default function parseAlgoliaHit({ * @param {string} highlightedValue - highlighted attribute as returned by Algolia highlight feature * @return {object[]} - An array of {value: string, isDefined: boolean}. */ -function parseHighlightedAttribute({ preTag, postTag, highlightedValue }) { +function parseHighlightedAttribute({ preTag, postTag, highlightedValue = '' }) { const splitByPreTag = highlightedValue.split(preTag); const firstValue = splitByPreTag.shift(); const elements = @@ -55,6 +68,7 @@ function parseHighlightedAttribute({ preTag, postTag, highlightedValue }) { } else { splitByPreTag.forEach(split => { const splitByPostTag = split.split(postTag); + elements.push({ value: splitByPostTag[0], isHighlighted: true, diff --git a/packages/react-instantsearch/src/core/highlight.test.js b/packages/react-instantsearch/src/core/highlight.test.js index 5220144788..1fc20c018a 100644 --- a/packages/react-instantsearch/src/core/highlight.test.js +++ b/packages/react-instantsearch/src/core/highlight.test.js @@ -69,6 +69,36 @@ describe('parseAlgoliaHit()', () => { ]); }); + it('supports the array format, parses it and returns the part that is highlighted', () => { + const hit = { + tags: ['litterature', 'biology', 'photography'], + _highlightResult: { + tags: [ + { value: 'litterature' }, + { value: 'biology' }, + { value: 'photography' }, + ], + }, + }; + + const actual = parseAlgoliaHit({ + attributeName: 'tags', + hit, + highlightProperty: '_highlightResult', + }); + + const exepectation = [ + [{ value: 'litterature', isHighlighted: false }], + [{ value: 'biology', isHighlighted: false }], + [ + { value: 'photo', isHighlighted: true }, + { value: 'graphy', isHighlighted: false }, + ], + ]; + + expect(actual).toEqual(exepectation); + }); + it('parses the string and returns the part that are highlighted - same pre and post tag', () => { const str = 'surpise **lo**l mouhahah roflmao **lo**utre'; const hit = createHit('attr', str); diff --git a/packages/react-instantsearch/src/widgets/Highlight.js b/packages/react-instantsearch/src/widgets/Highlight.js index 7d45d6bd26..f01e521f6d 100644 --- a/packages/react-instantsearch/src/widgets/Highlight.js +++ b/packages/react-instantsearch/src/widgets/Highlight.js @@ -2,15 +2,17 @@ import connectHighlight from '../connectors/connectHighlight.js'; import HighlightComponent from '../components/Highlight.js'; /** - * Renders any attribute from an hit into its highlighted form when relevant. + * Renders any attribute from a hit into its highlighted form when relevant. * * Read more about it in the [Highlighting results](guide/Highlighting_results.html) guide. * @name Highlight * @kind widget - * @propType {string} attributeName - the location of the highlighted attribute in the hit - * @propType {object} hit - the hit object containing the highlighted attribute - * @propType {string} [tagName='em'] - the tag to be used for highlighted parts of the hit - * @themeKey ais-Highlight - the root of the component + * @propType {string} attributeName - location of the highlighted attribute in the hit (the corresponding element can be either a string or an array of strings) + * @propType {object} hit - hit object containing the highlighted attribute + * @propType {string} [tagName='em'] - tag to be used for highlighted parts of the hit + * @propType {string} [nonHighlightedTagName='span'] - tag to be used for the parts of the hit that are not highlighted + * @propType {React.Element} [separatorComponent=','] - symbol used to separate the elements of the array in case the attributeName points to an array of strings. + * @themeKey ais-Highlight - root of the component * @themeKey ais-Highlight__highlighted - part of the text that is highlighted * @themeKey ais-Highlight__nonHighlighted - part of the text that is non highlighted * @example diff --git a/packages/react-instantsearch/src/widgets/Snippet.js b/packages/react-instantsearch/src/widgets/Snippet.js index 710bfe3c9c..7c1880ebe7 100644 --- a/packages/react-instantsearch/src/widgets/Snippet.js +++ b/packages/react-instantsearch/src/widgets/Snippet.js @@ -10,9 +10,11 @@ import SnippetComponent from '../components/Snippet.js'; * @requirements To use this widget, the attribute name passed to the `attributeName` prop must be * present in "Attributes to snippet" on the Algolia dashboard or configured as `attributesToSnippet` * via a set settings call to the Algolia API. - * @propType {string} attributeName - the location of the highlighted snippet attribute in the hit - * @propType {object} hit - the hit object containing the highlighted snippet attribute - * @propType {string} [tagName='em'] - the tag to be used for highlighted parts of the attribute + * @propType {string} attributeName - location of the highlighted snippet attribute in the hit (the corresponding element can be either a string or an array of strings) + * @propType {object} hit - hit object containing the highlighted snippet attribute + * @propType {string} [tagName='em'] - tag to be used for highlighted parts of the attribute + * @propType {string} [nonHighlightedTagName='span'] - tag to be used for the parts of the hit that are not highlighted + * @propType {React.Element} [separatorComponent=','] - symbol used to separate the elements of the array in case the attributeName points to an array of strings. * @example * import React from 'react'; * import { Snippet, InstantSearch, Hits } from 'react-instantsearch/dom'; diff --git a/stories/Highlight.stories.js b/stories/Highlight.stories.js index 3eb3cfd84a..9ed6121b5f 100644 --- a/stories/Highlight.stories.js +++ b/stories/Highlight.stories.js @@ -1,9 +1,17 @@ import PropTypes from 'prop-types'; -import React from 'react'; -import { Highlight, Hits } from '../packages/react-instantsearch/dom'; +import React, { Component } from 'react'; +import { + ClearAll, + Highlight, + Hits, + InstantSearch, + Pagination, + SearchBox, +} from '../packages/react-instantsearch/dom'; import { storiesOf } from '@storybook/react'; +import { connectHits } from '../packages/react-instantsearch/connectors'; import { text } from '@storybook/addon-knobs'; -import { WrapWithHits } from './util'; +import { displayName, filterProps, WrapWithHits } from './util'; const stories = storiesOf('Highlight', module); @@ -56,3 +64,47 @@ stories )); + +const CustomHits = connectHits(({ hits }) => ( +
+ {hits.map(hit => ( +
+
+
+ +
+ +
+ +
+
+
+ ))} +
+)); + +class AppWithArray extends Component { + render() { + return ( + + + + + + + ); + } +} + +stories.add('highlight on array', () => , { + displayName, + filterProps, +});