Skip to content

Commit

Permalink
feat: hierarchicalWidget
Browse files Browse the repository at this point in the history
Add hierarchicalWidget.

+ add limit prop to RefinementList (will then PR other refinement
widget to use it)
+ add `Requirements` section to documentation. We should have it for
every widget
  • Loading branch information
vvo committed Sep 30, 2015
1 parent 2f247ad commit 1facd9d
Show file tree
Hide file tree
Showing 9 changed files with 209 additions and 6 deletions.
57 changes: 57 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,7 @@ npm run test:watch # developer mode, test only
[hits]: ./widgets-screenshots/hits.png
[toggle]: ./widgets-screenshots/toggle.png
[refinementList]: ./widgets-screenshots/refinement-list.png
[hierarchicalMenu]: ./widgets-screenshots/hierarchicalMenu.png
[menu]: ./widgets-screenshots/menu.png
[rangeSlider]: ./widgets-screenshots/range-slider.png
[urlSync]: ./widgets-screenshots/url-sync.gif
Expand Down Expand Up @@ -662,6 +663,62 @@ search.addWidget(
);
```

### hierarchicalMenu

![Example of the hierarchicalMenu widget][hierarchicalMenu]

#### API

```js
/**
* Create a hierarchical menu using multiple attributes
* @param {String|DOMElement} options.container CSS Selector or DOMElement to insert the widget
* @param {String[]} options.attributes Array of attributes to use to generate the hierarchy of the menu.
* You need to follow some conventions:
* @param {String[]} [options.sortBy=['count:desc']] How to sort refinements. Possible values: `count|isRefined|name:asc|desc`
* @param {Number} [options.limit=100] How much facet values to get
* @param {Object} [options.cssClasses] CSS classes to add to the wrapping elements: root, list, item
* @param {String|String[]} [options.cssClasses.root]
* @param {String|String[]} [options.cssClasses.list]
* @param {String|String[]} [options.cssClasses.item]
* @param {Object} [options.templates] Templates to use for the widget
* @param {String|Function} [options.templates.header=''] Header template (root level only)
* @param {String|Function} [options.templates.item='<a href="{{href}}">{{name}}</a> {{count}}'] Item template, provided with `name`, `count`, `isRefined`, `path`
* @param {String|Function} [options.templates.footer=''] Footer template (root level only)
* @param {Function} [options.transformData] Method to change the object passed to the item template
* @param {boolean} [hideWhenNoResults=true] Hide the container when there's no results
* @return {Object}
*/
```

#### Algolia requirements

All the `attributes` should be added to `attributesForFaceting` in your index settings.

Your index's objects must be formatted in a way that is expected by the `hierarchicalMenu` widget:

```json
{
"objectID": "123",
"name": "orange",
"categories": {
"lvl0": "fruits",
"lvl1": "fruits > citrus"
}
}
```

#### Usage

```js
search.addWidget(
instantsearch.widgets.hierarchicalMenu({
container: '#products',
attributes: ['categories.lvl0', 'categories.lvl1', 'categories.lvl2']
})
);
```

## Browser support

We natively support IE10+ and all other modern browsers without any dependency need
Expand Down
24 changes: 19 additions & 5 deletions components/RefinementList.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,10 @@ class RefinementList extends React.Component {
// has a checkbox inside, we ignore the first click event because we will get another one.
handleClick(value, e) {
if (e.target.tagName === 'A' && e.target.href) {
// do not trigger any url change by the href
e.preventDefault();
// do not bubble (so that hierarchical lists are not triggering refine twice)
e.stopPropagation();
}

if (e.target.tagName === 'INPUT') {
Expand All @@ -43,14 +46,21 @@ class RefinementList extends React.Component {
render() {
return (
<div className={cx(this.props.cssClasses.list)}>
{this.props.facetValues.map(facetValue => {
{this.props.facetValues.slice(0, this.props.limit).map(facetValue => {
var hasChildren = facetValue.data && facetValue.data.length > 0;

var subList = hasChildren ?
<RefinementList {...this.props} facetValues={facetValue.data} /> :
null;

return (
<div
className={cx(this.props.cssClasses.item)}
key={facetValue.name}
onClick={this.handleClick.bind(this, facetValue.name)}
key={facetValue[this.props.facetNameKey]}
onClick={this.handleClick.bind(this, facetValue[this.props.facetNameKey])}
>
<this.props.Template data={facetValue} templateKey="item" />
{subList}
</div>
);
})}
Expand All @@ -70,16 +80,20 @@ RefinementList.propTypes = {
React.PropTypes.arrayOf(React.PropTypes.string)
])
}),
limit: React.PropTypes.number,
facetValues: React.PropTypes.array,
Template: React.PropTypes.func,
toggleRefinement: React.PropTypes.func.isRequired
toggleRefinement: React.PropTypes.func.isRequired,
facetNameKey: React.PropTypes.string
};

RefinementList.defaultProps = {
cssClasses: {
item: null,
list: null
}
},
limit: 1000,
facetNameKey: 'name'
};

module.exports = RefinementList;
15 changes: 15 additions & 0 deletions example/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -139,4 +139,19 @@ search.addWidget(
})
);

search.addWidget(
instantsearch.widgets.hierarchicalMenu({
container: '#hierarchical-categories',
attributes: ['hierarchicalCategories.lvl0', 'hierarchicalCategories.lvl1', 'hierarchicalCategories.lvl2'],
cssClasses: {
root: 'list-group',
list: 'hierarchical-categories-list'
},
templates: {
header: '<div class="panel-heading">Hierarchical categories</div>',
item: require('./templates/category.html')
}
})
);

search.start();
2 changes: 2 additions & 0 deletions example/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ <h1>Instant search demo <small>using instantsearch.js</small></h1>

<div class="panel panel-default" id="price"></div>

<div class="panel panel-default" id="hierarchical-categories"></div>

</div>
<div class="col-md-9">
<div class="row">
Expand Down
4 changes: 4 additions & 0 deletions example/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,7 @@ body {
background-image: linear-gradient(to bottom,#f5f5f5 0,#e8e8e8 100%);
border-color: #ddd;
}

.hierarchical-categories-list .hierarchical-categories-list {
padding-left: 20px;
}
1 change: 1 addition & 0 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ var InstantSearch = require('./lib/InstantSearch');
var instantsearch = toFactory(InstantSearch);

instantsearch.widgets = {
hierarchicalMenu: require('./widgets/hierarchicalMenu'),
hits: require('./widgets/hits'),
indexSelector: require('./widgets/index-selector'),
menu: require('./widgets/menu'),
Expand Down
Binary file added widgets-screenshots/hierarchicalMenu.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
110 changes: 110 additions & 0 deletions widgets/hierarchicalMenu.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
var React = require('react');

var utils = require('../lib/utils.js');
var autoHide = require('../decorators/autoHide');
var bindProps = require('../decorators/bindProps');
var headerFooter = require('../decorators/headerFooter');
var RefinementList = autoHide(headerFooter(require('../components/RefinementList')));
var Template = require('../components/Template');

var hierarchicalCounter = 0;
var defaultTemplates = {
header: '',
item: '<a href="{{href}}">{{name}}</a> {{count}}',
footer: ''
};

/**
* Create a hierarchical menu using multiple attributes
* @param {String|DOMElement} options.container CSS Selector or DOMElement to insert the widget
* @param {String[]} options.attributes Array of attributes to use to generate the hierarchy of the menu.
* You need to follow some conventions:
* @param {String[]} [options.sortBy=['count:desc']] How to sort refinements. Possible values: `count|isRefined|name:asc|desc`
* @param {Number} [options.limit=100] How much facet values to get
* @param {Object} [options.cssClasses] CSS classes to add to the wrapping elements: root, list, item
* @param {String|String[]} [options.cssClasses.root]
* @param {String|String[]} [options.cssClasses.list]
* @param {String|String[]} [options.cssClasses.item]
* @param {Object} [options.templates] Templates to use for the widget
* @param {String|Function} [options.templates.header=''] Header template (root level only)
* @param {String|Function} [options.templates.item='<a href="{{href}}">{{name}}</a> {{count}}'] Item template, provided with `name`, `count`, `isRefined`, `path`
* @param {String|Function} [options.templates.footer=''] Footer template (root level only)
* @param {Function} [options.transformData] Method to change the object passed to the item template
* @param {boolean} [hideWhenNoResults=true] Hide the container when there's no results
* @return {Object}
*/
function hierarchicalMenu({
container = null,
attributes = [],
separator,
limit = 100,
sortBy = ['name:asc'],
cssClasses = {
root: null,
list: null,
item: null
},
hideWhenNoResults = true,
templates = defaultTemplates,
transformData
}) {
hierarchicalCounter++;

var containerNode = utils.getContainerNode(container);
var usage = 'Usage: hierarchicalMenu({container, attributes, [separator, sortBy, limit, cssClasses.{root, list, item}, templates.{header, item, footer}, transformData]})';

if (!container || !attributes || !attributes.length) {
throw new Error(usage);
}

var hierarchicalFacetName = 'instantsearch.js-hierarchicalMenu' + hierarchicalCounter;

return {
getConfiguration: () => ({
hierarchicalFacets: [{
name: hierarchicalFacetName,
attributes,
separator
}]
}),
render: function({results, helper, templatesConfig}) {
var facetValues = getFacetValues(results, hierarchicalFacetName, sortBy);

var templateProps = utils.prepareTemplateProps({
transformData,
defaultTemplates,
templatesConfig,
templates
});

React.render(
<RefinementList
cssClasses={cssClasses}
facetValues={facetValues}
limit={limit}
Template={bindProps(Template, templateProps)}
hideWhenNoResults={hideWhenNoResults}
hasResults={facetValues.length > 0}
facetNameKey="path"
toggleRefinement={toggleRefinement.bind(null, helper, hierarchicalFacetName)}
/>,
containerNode
);
}
};
}

function toggleRefinement(helper, facetName, facetValue) {
helper
.toggleRefinement(facetName, facetValue)
.search();
}

function getFacetValues(results, hierarchicalFacetName, sortBy) {
var values = results
.getFacetValues(hierarchicalFacetName, {sortBy: sortBy});

return values.data || [];
}

module.exports = hierarchicalMenu;
2 changes: 1 addition & 1 deletion widgets/menu.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ function menu({
throw new Error(usage);
}

var hierarchicalFacetName = 'instantsearch.js' + hierarchicalCounter;
var hierarchicalFacetName = 'instantsearch.js-menu' + hierarchicalCounter;

return {
getConfiguration: () => ({
Expand Down

0 comments on commit 1facd9d

Please sign in to comment.