Skip to content

Commit

Permalink
feat(snippet): add a snippet widget to be able to highlight snippet r…
Browse files Browse the repository at this point in the history
…esults (#1797)
  • Loading branch information
mthuret authored and bobylito committed Jan 4, 2017
1 parent 1a66a08 commit 2aecc40
Show file tree
Hide file tree
Showing 13 changed files with 193 additions and 34 deletions.
23 changes: 14 additions & 9 deletions docgen/src/guide/Highlighting_results.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,19 @@ the results.

This feature is already packaged for you in react-instantsearch and
like most of its features it comes in two flavors, depending on your use case:
- when using the DOM, the widget is the way to go
- when using the DOM, widgets is the way to go
- when using another rendering (such as react native), you will use the connector

## <Highlight> widget
## <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 widget takes two props:
to use the Highlighter. The Highlight and the Snippet widgets takes two props:
- attributeName: the path to the highlighted attribute
- hit: a single result object

**Notes:**
* Use the `<Highlight>` widget when you want to display the regular value of an attribute.
* Use the `<Snippet>` widget when you want to display the snippet version of an attribute.

Here is an example in which we create a custom Hit widget for results that have a
`description` field that is highlighted.
Expand Down Expand Up @@ -52,23 +56,24 @@ export default function App() {
## connectHighlight connector

The connector provides a function that will extract the highlighting data
from the results. This function takes a single parameter object with two
from the results. This function takes a single parameter object with three
properties:
- attributeName: the path to the highlighted attribute
- attributeName: the highlighted attribute name
- hit: a single result object
- path: the path to the structure containing the highlighted attribute

Those parameters are taken from the context in which the the custom component
is used, therefore it's reasonnable to have them as props.
is used, therefore it's reasonable to have them as props.

Here is an example of a custom Highlight widget. It can be used the same
way the [Highlight widget](guide/Highlighting_results.html#highlight-widget).
way as the [widgets](guide/Highlighting_results.html#highlight-and-snippet-widgets).

```javascript
const CustomHighlight = connectHighlight(({highlight, attributeName, hit}) => {
const parsedHit = highlight({attributeName, hit});
const parsedHit = highlight({attributeName, hit, highlightProperty: 'highlightProperty'});
return parsedHit.map(part => {
if(part.isHighlighted) return <em>{part.value}</em>;
return part.value:
return part.value;
});
});
```
Expand Down
1 change: 1 addition & 0 deletions packages/react-instantsearch/dom.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export {InstantSearch};
export {default as CurrentRefinements} from './src/widgets/CurrentRefinements.js';
export {default as HierarchicalMenu} from './src/widgets/HierarchicalMenu.js';
export {default as Highlight} from './src/widgets/Highlight.js';
export {default as Snippet} from './src/widgets/Snippet.js';
export {default as Hits} from './src/widgets/Hits.js';
export {default as HitsPerPage} from './src/widgets/HitsPerPage.js';
export {default as InfiniteHits} from './src/widgets/InfiniteHits.js';
Expand Down
14 changes: 4 additions & 10 deletions packages/react-instantsearch/src/components/Highlight.js
Original file line number Diff line number Diff line change
@@ -1,19 +1,13 @@
import React from 'react';
import Highlighter from './Highlighter';

export default function Highlight({hit, attributeName, highlight}) {
const parsedHighlightedValue = highlight({hit, attributeName});
const reactHighlighted = parsedHighlightedValue.map((v, i) => {
const key = `split-${i}-${v.value}`;
if (!v.isHighlighted) {
return <span key={key} className="ais-Highlight__nonHighlighted">{v.value}</span>;
}
return <em key={key} className="ais-Highlight__highlighted">{v.value}</em>;
});
return <span className="ais-Highlight">{reactHighlighted}</span>;
export default function Highlight(props) {
return <Highlighter highlightProperty="_highlightResult" {...props}/>;
}

Highlight.propTypes = {
hit: React.PropTypes.object.isRequired,
attributeName: React.PropTypes.string.isRequired,
highlight: React.PropTypes.func.isRequired,
};

Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,12 @@ describe('Highlight', () => {
},
};

const highlight = ({hit, attributeName}) => parseAlgoliaHit({
const highlight = ({hit, attributeName, highlightProperty}) => parseAlgoliaHit({
preTag: '<ais-highlight>',
postTag: '</ais-highlight>',
attributeName,
hit,
highlightProperty,
});

const tree = renderer.create(
Expand Down
20 changes: 20 additions & 0 deletions packages/react-instantsearch/src/components/Highlighter.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import React from 'react';

export default function Highlighter({hit, attributeName, highlight, highlightProperty}) {
const parsedHighlightedValue = highlight({hit, attributeName, highlightProperty});
const reactHighlighted = parsedHighlightedValue.map((v, i) => {
const key = `split-${i}-${v.value}`;
if (!v.isHighlighted) {
return <span key={key} className="ais-Highlight__nonHighlighted">{v.value}</span>;
}
return <em key={key} className="ais-Highlight__highlighted">{v.value}</em>;
});
return <span className="ais-Highlight">{reactHighlighted}</span>;
}

Highlighter.propTypes = {
hit: React.PropTypes.object.isRequired,
attributeName: React.PropTypes.string.isRequired,
highlight: React.PropTypes.func.isRequired,
highlightProperty: React.PropTypes.string.isRequired,
};
44 changes: 44 additions & 0 deletions packages/react-instantsearch/src/components/Highlighter.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/* eslint-env jest, jasmine */

import React from 'react';
import renderer from 'react-test-renderer';

import Highlighter 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 <ais-highlight>hi</ais-highlight>ghlighted <ais-highlight>hi</ais-highlight>t!',
fullyHighlighted: true,
matchLevel: 'full',
matchedWords: [''],
},
},
},
},
};

const highlight = ({hit, attributeName, highlightProperty}) => parseAlgoliaHit({
preTag: '<ais-highlight>',
postTag: '</ais-highlight>',
attributeName,
hit,
highlightProperty,
});

const tree = renderer.create(
<Highlighter attributeName="deep.attribute.value"
hit={hitFromAPI}
highlight={highlight}
highlightProperty="_highlightProperty"/>
);
expect(tree.toJSON()).toMatchSnapshot();
});
});
13 changes: 13 additions & 0 deletions packages/react-instantsearch/src/components/Snippet.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import React from 'react';

import Highlighter from './Highlighter';

export default function Snippet(props) {
return <Highlighter highlightProperty="_snippetResult" {...props}/>;
}

Snippet.propTypes = {
hit: React.PropTypes.object.isRequired,
attributeName: React.PropTypes.string.isRequired,
highlight: React.PropTypes.func.isRequired,
};
37 changes: 37 additions & 0 deletions packages/react-instantsearch/src/components/Snippet.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/* eslint-env jest, jasmine */

import React from 'react';
import renderer from 'react-test-renderer';

import Snippet from './Snippet';
import parseAlgoliaHit from '../core/highlight';

describe('Snippet', () => {
it('parses an highlighted snippet attribute of hit object', () => {
const hitFromAPI = {
objectID: 0,
deep: {attribute: {value: 'awesome highlighted hit!'}},
_snippetResults: {
deep: {attribute: {value: {
value: 'awesome <ais-highlight>hi</ais-highlight>ghlighted <ais-highlight>hi</ais-highlight>t!',
fullyHighlighted: true,
matchLevel: 'full',
matchedWords: [''],
}}},
},
};

const highlight = ({hit, attributeName, highlightProperty}) => parseAlgoliaHit({
preTag: '<ais-highlight>',
postTag: '</ais-highlight>',
attributeName,
hit,
highlightProperty,
});

const tree = renderer.create(
<Snippet attributeName="deep.attribute.value" hit={hitFromAPI} highlight={highlight}/>
);
expect(tree.toJSON()).toMatchSnapshot();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@ import parseAlgoliaHit from '../core/highlight';

import highlightTags from '../core/highlightTags.js';

const highlight = ({attributeName, hit}) => parseAlgoliaHit({
const highlight = ({attributeName, hit, highlightProperty}) => parseAlgoliaHit({
attributeName,
hit,
preTag: highlightTags.highlightPreTag,
postTag: highlightTags.highlightPostTag,
highlightProperty,
});

/**
Expand All @@ -17,10 +18,10 @@ const highlight = ({attributeName, hit}) => parseAlgoliaHit({
* @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 2 attribute: `attributeName` which is the path to the attribute in the record, and `hit` which is the hit from Algolia. It returns an array of object `{value: string, isHighlighted: boolean}`.
* @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}`.
* @example
* const CustomHighlight = connectHighlight(({highlight, attributeName, hit) => {
* const parsedHit = highlight({attributeName, hit});
* const CustomHighlight = connectHighlight(({highlight, attributeName, hit, highlightProperty) => {
* const parsedHit = highlight({attributeName, hit, highlightProperty});
* return parsedHit.map(part => {
* if(part.isHighlighted) return <em>{part.value}</em>;
* return part.value:
Expand Down
10 changes: 6 additions & 4 deletions packages/react-instantsearch/src/core/highlight.js
Original file line number Diff line number Diff line change
@@ -1,29 +1,31 @@
import {get} from 'lodash';

/**
* Find an highlighted attribute give a path `attributeName`, parses it,
* Find an highlighted attribute given an `attributeName` and an `highlightProperty`, parses it,
* and provided an array of objects with the string value and a boolean if this
* value is highlighted.
*
* In order to use this feature, highlight must be activated in the configruation of
* In order to use this feature, highlight must be activated in the configuration of
* the index. The `preTag` and `postTag` attributes are respectively highlightPreTag and
* highligtPostTag in Algolia configuration.
*
* @param {string} preTag - string used to identify the start of an highlighted value
* @param {string} postTag - string used to identify the end of an highlighted value
* @param {string} attributeName - path to the highlighted attribute in the results
* @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}.
*/
export default function parseAlgoliaHit({
preTag = '<em>',
postTag = '</em>',
highlightProperty,
attributeName,
hit,
}) {
if (!hit) throw new Error('`hit`, the matching record, must be provided');

const highlightObject = get(hit._highlightResult, attributeName);
const highlightObject = get(hit[highlightProperty], attributeName);
const highlightedValue = !highlightObject ? '' : highlightObject.value;

return parseHighlightedAttribute({preTag, postTag, highlightedValue});
Expand Down
19 changes: 14 additions & 5 deletions packages/react-instantsearch/src/core/highlight.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,28 @@ import parseAlgoliaHit from './highlight.js';
describe('parseAlgoliaHit()', () => {
it('it does not break when there is a missing attribute', () => {
const attributeName = 'attr';
const out = parseAlgoliaHit({attributeName, hit: {}});
const out = parseAlgoliaHit({attributeName, hit: {}, highlightProperty: '_highlightResult'});
expect(out).toEqual([]);
});

it('creates a single element when there is no tag', () => {
const value = 'foo bar baz';
const attributeName = 'attr';
const out = parseAlgoliaHit({attributeName, hit: createHit(attributeName, value)});
const out = parseAlgoliaHit({
attributeName, hit: createHit(attributeName, value),
highlightProperty: '_highlightResult',
});
expect(out).toEqual([{isHighlighted: false, value}]);
});

it('creates a single element when there is only a tag', () => {
const textValue = 'foo bar baz';
const value = `<em>${textValue}</em>`;
const attributeName = 'attr';
const out = parseAlgoliaHit({attributeName, hit: createHit(attributeName, value)});
const out = parseAlgoliaHit({
attributeName, hit: createHit(attributeName, value),
highlightProperty: '_highlightResult',
});
expect(out).toEqual([{value: textValue, isHighlighted: true}]);
});

Expand All @@ -32,14 +38,14 @@ describe('parseAlgoliaHit()', () => {
lvl0: {lvl1: {lvl2: {value}}},
},
};
const out = parseAlgoliaHit({attributeName: 'lvl0.lvl1.lvl2', hit});
const out = parseAlgoliaHit({attributeName: 'lvl0.lvl1.lvl2', hit, highlightProperty: '_highlightResult'});
expect(out).toEqual([{value: textValue, isHighlighted: true}]);
});

it('parses the string and returns the part that are highlighted - 1 big highlight', () => {
const str = 'like <em>al</em>golia does <em>al</em>golia';
const hit = createHit('attr', str);
const parsed = parseAlgoliaHit({attributeName: 'attr', hit});
const parsed = parseAlgoliaHit({attributeName: 'attr', hit, highlightProperty: '_highlightResult'});
expect(parsed).toEqual([
{value: 'like ', isHighlighted: false},
{value: 'al', isHighlighted: true},
Expand All @@ -57,6 +63,7 @@ describe('parseAlgoliaHit()', () => {
postTag: '**',
attributeName: 'attr',
hit,
highlightProperty: '_highlightResult',
});
expect(parsed).toEqual([
{value: 'surpise ', isHighlighted: false},
Expand All @@ -71,13 +78,15 @@ describe('parseAlgoliaHit()', () => {
expect(parseAlgoliaHit.bind(null, {
attributeName: 'unknownattribute',
hit: null,
highlightProperty: '_highlightResult',
})).toThrowError('`hit`, the matching record, must be provided');
});

it('throws when hit is `undefined`', () => {
expect(parseAlgoliaHit.bind(null, {
attributeName: 'unknownAttribute',
hit: undefined,
highlightProperty: '_highlightResult',
})).toThrowError('`hit`, the matching record, must be provided');
});
});
Expand Down
31 changes: 31 additions & 0 deletions packages/react-instantsearch/src/widgets/Snippet.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import connectHighlight from '../connectors/connectHighlight.js';
import SnippetComponent from '../components/Snippet.js';

/**
* Renders an highlighted snippet attribute.
* @name Snippet
* @kind widget
* @propType {string} attributeName - the location of the highlighted snippet attribute in the hit
* @propType {object} hit - the hit object containing the highlighted snippet attribute
* @example
* import React from 'react';
*
* import {InstantSearch, connectHits, Snippet} from 'InstantSearch';
*
* const CustomHits = connectHits(hits => {
* return hits.map((hit) => <p><Snippet attributeName='description' hit={hit}/></p>);
* });
*
* export default function App() {
* return (
* <InstantSearch
* appId="latency"
* apiKey="6be0576ff61c053d5f9a3225e2a90f76"
* indexName="ikea"
* >
* <CustomHits />
* </InstantSearch>
* );
* }
*/
export default connectHighlight(SnippetComponent);
Loading

0 comments on commit 2aecc40

Please sign in to comment.