Skip to content

Commit a29e9c7

Browse files
committed
feat(numericRefinementList): create numericRefinementList widget using refinementList component
1 parent 848eec1 commit a29e9c7

File tree

12 files changed

+477
-5
lines changed

12 files changed

+477
-5
lines changed

components/RefinementList/RefinementList.js

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,14 @@ class RefinementList extends React.Component {
3535
[this.props.cssClasses.active]: facetValue.isRefined
3636
});
3737

38-
let key = facetValue[this.props.facetNameKey] + '/' + facetValue.isRefined + '/' + facetValue.count;
38+
let key = facetValue[this.props.facetNameKey];
39+
if (facetValue.isRefined !== undefined) {
40+
key += '/' + facetValue.isRefined;
41+
}
42+
43+
if (facetValue.count !== undefined) {
44+
key += '/' + facetValue.count;
45+
}
3946
return (
4047
<div
4148
className={cssClassItem}
@@ -79,7 +86,8 @@ class RefinementList extends React.Component {
7986
let parent = e.target;
8087

8188
while (parent !== e.currentTarget) {
82-
if (parent.tagName === 'LABEL' && parent.querySelector('input[type="checkbox"]')) {
89+
if (parent.tagName === 'LABEL' && (parent.querySelector('input[type="checkbox"]')
90+
|| parent.querySelector('input[type="radio"]'))) {
8391
return;
8492
}
8593

components/RefinementList/__tests__/RefinementList-test.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,8 @@ describe('RefinementList', () => {
5656
</div>
5757
</div>
5858
);
59-
expect(out.props.children[0].key).toEqual('facet1/undefined/undefined');
60-
expect(out.props.children[1].key).toEqual('facet2/undefined/undefined');
59+
expect(out.props.children[0].key).toEqual('facet1');
60+
expect(out.props.children[1].key).toEqual('facet2');
6161
});
6262

6363
it('should render default list highlighted', () => {

dev/app.js

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,30 @@ search.addWidget(
9292
})
9393
);
9494

95+
search.addWidget(
96+
instantsearch.widgets.numericRefinementList({
97+
container: '#price-numeric-list',
98+
attributeName: 'price',
99+
operator: 'or',
100+
options: [
101+
{name: 'All'},
102+
{end: 4, name: 'less than 4'},
103+
{start: 4, end: 4, name: '4'},
104+
{start: 5, end: 10, name: 'between 5 and 10'},
105+
{start: 10, name: 'more than 10'}
106+
],
107+
cssClasses: {
108+
header: 'facet-title',
109+
link: 'facet-value',
110+
count: 'facet-count pull-right',
111+
active: 'facet-active'
112+
},
113+
templates: {
114+
header: 'Price numeric list'
115+
}
116+
})
117+
);
118+
95119
search.addWidget(
96120
instantsearch.widgets.refinementList({
97121
container: '#price-range',
@@ -187,4 +211,22 @@ search.once('render', function() {
187211
document.querySelector('.search').className = 'row search search--visible';
188212
});
189213

214+
search.addWidget(
215+
instantsearch.widgets.priceRanges({
216+
container: '#price-ranges',
217+
attributeName: 'price',
218+
templates: {
219+
header: 'Price ranges'
220+
},
221+
cssClasses: {
222+
header: 'facet-title',
223+
body: 'nav nav-stacked',
224+
range: 'facet-value',
225+
form: '',
226+
input: 'fixed-input-sm',
227+
button: 'btn btn-default btn-sm'
228+
}
229+
})
230+
);
231+
190232
search.start();

dev/index.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ <h1><a href="./">Instant search demo</a> <small>using instantsearch.js</small></
2828
<div class="facet" id="price"></div>
2929
<div class="facet" id="categories"></div>
3030
<div class="facet" id="price-ranges"></div>
31+
<div class="facet" id="price-numeric-list"></div>
3132
</div>
3233
<div class="col-md-9">
3334
<div class="form-group">

dev/style.css

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,3 +110,7 @@ body {
110110
.ais-price-ranges .ais-price-ranges--input{
111111
width: 65px;
112112
}
113+
114+
.ais-refinement-list--label input[type=radio]{
115+
margin: 4px 8px 0 0;
116+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
| Param | Description |
2+
| --- | --- |
3+
| <span class='attr-required'>`options.container`</span> | CSS Selector or DOMElement to insert the widget |
4+
| <span class='attr-required'>`options.attributeName`</span> | Name of the attribute for filtering |
5+
| <span class='attr-optional'>`options.cssClasses`</span> | CSS classes to add to the wrapping elements: root, list, item |
6+
| <span class='attr-optional'>`options.cssClasses.root`</span> | CSS class to add to the root element |
7+
| <span class='attr-optional'>`options.cssClasses.header`</span> | CSS class to add to the header element |
8+
| <span class='attr-optional'>`options.cssClasses.body`</span> | CSS class to add to the body element |
9+
| <span class='attr-optional'>`options.cssClasses.footer`</span> | CSS class to add to the footer element |
10+
| <span class='attr-optional'>`options.cssClasses.list`</span> | CSS class to add to the list element |
11+
| <span class='attr-optional'>`options.cssClasses.label`</span> | CSS class to add to each link element |
12+
| <span class='attr-optional'>`options.cssClasses.item`</span> | CSS class to add to each item element |
13+
| <span class='attr-optional'>`options.cssClasses.radio`</span> | CSS class to add to each radio element (when using the default template) |
14+
| <span class='attr-optional'>`options.cssClasses.active`</span> | CSS class to add to each active element |
15+
| <span class='attr-optional'>`options.templates`</span> | Templates to use for the widget |
16+
| <span class='attr-optional'>`options.templates.header`</span> | Header template |
17+
| <span class='attr-optional'>`options.templates.item`</span> | Item template, provided with `name`, `count`, `isRefined` |
18+
| <span class='attr-optional'>`options.templates.footer`</span> | Footer template |
19+
| <span class='attr-optional'>`options.transformData`</span> | Function to change the object passed to the item template |
20+
| <span class='attr-optional'>`hideContainerWhenNoResults`</span> | Hide the container when there's no results |
21+
22+
<p class="attr-legend">* <span>Required</span></p>

docs/documentation.md

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -532,6 +532,53 @@ results that have either the value `a` or `b` will match.
532532

533533
<div id="brands" class="widget-container"></div>
534534

535+
#### numericRefinementList
536+
537+
<div class="code-box">
538+
<div class="code-sample-snippet">
539+
{% highlight javascript %}
540+
search.addWidget(
541+
instantsearch.widgets.numericRefinementList({
542+
container: '#popularity',
543+
attributeName: 'popularity',
544+
options: [
545+
{name: 'All'},
546+
{end: 4, name: 'less than 4'},
547+
{start: 4, end: 4, name: '4'},
548+
{start: 5, end: 10, name: 'between 5 and 10'},
549+
{start: 10, name: 'more than 10'}
550+
],
551+
templates: {
552+
header: 'Price'
553+
},
554+
cssClasses: {
555+
root: '',
556+
header: '',
557+
body: '',
558+
footer: '',
559+
list: '',
560+
link: '',
561+
active: ''
562+
}
563+
})
564+
);
565+
{% endhighlight %}
566+
</div>
567+
<div class="jsdoc" style='display:none'>
568+
{% highlight javascript %}
569+
instantsearch.widgets.numericRefinementList(options);
570+
{% endhighlight %}
571+
572+
{% include widget-jsdoc/refinementList.md %}
573+
</div>
574+
</div>
575+
576+
<img class="widget-icon pull-left" src="../img/icon-widget-refinement.svg">
577+
This filtering widget lets the user choose one value for a single numeric attribute. You can specify if you want it to be a equality or a range by giving a "start" and an "end" value
578+
{:.description}
579+
580+
<div id="popularity" class="widget-container"></div>
581+
535582
#### toggle
536583

537584
<div class="code-box">

lib/main.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ instantsearch.widgets = {
1414
indexSelector: require('../widgets/index-selector/index-selector'),
1515
menu: require('../widgets/menu/menu.js'),
1616
refinementList: require('../widgets/refinement-list/refinement-list.js'),
17+
numericRefinementList: require('../widgets/numeric-refinement-list/numeric-refinement-list.js'),
1718
pagination: require('../widgets/pagination/pagination'),
1819
priceRanges: require('../widgets/price-ranges/price-ranges.js'),
1920
searchBox: require('../widgets/search-box/search-box'),

npm-shrinkwrap.json

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
/* eslint-env mocha */
2+
3+
import React from 'react';
4+
import expect from 'expect';
5+
import sinon from 'sinon';
6+
import jsdom from 'mocha-jsdom';
7+
8+
import expectJSX from 'expect-jsx';
9+
expect.extend(expectJSX);
10+
11+
describe('numericRefinementList()', () => {
12+
jsdom({useEach: true});
13+
14+
let ReactDOM;
15+
let container;
16+
let widget;
17+
let helper;
18+
19+
let autoHideContainer;
20+
let headerFooter;
21+
let RefinementList;
22+
let numericRefinementList;
23+
let options;
24+
25+
beforeEach(() => {
26+
numericRefinementList = require('../numeric-refinement-list');
27+
RefinementList = require('../../../components/RefinementList/RefinementList');
28+
ReactDOM = {render: sinon.spy()};
29+
numericRefinementList.__Rewire__('ReactDOM', ReactDOM);
30+
autoHideContainer = sinon.stub().returns(RefinementList);
31+
numericRefinementList.__Rewire__('autoHideContainer', autoHideContainer);
32+
headerFooter = sinon.stub().returns(RefinementList);
33+
numericRefinementList.__Rewire__('headerFooter', headerFooter);
34+
35+
options = [
36+
{name: 'All'},
37+
{end: 4, name: 'less than 4'},
38+
{start: 4, end: 4, name: '4'},
39+
{start: 5, end: 10, name: 'between 5 and 10'},
40+
{start: 10, name: 'more than 10'}
41+
];
42+
43+
container = document.createElement('div');
44+
widget = numericRefinementList({container, attributeName: 'price', options: options});
45+
helper = {
46+
state: {
47+
getNumericRefinements: sinon.stub().returns([])
48+
},
49+
addNumericRefinement: sinon.spy(),
50+
search: sinon.spy(),
51+
setState: sinon.spy()
52+
};
53+
54+
helper.state.clearRefinements = sinon.stub().returns(helper.state);
55+
helper.state.addNumericRefinement = sinon.stub().returns(helper.state);
56+
});
57+
58+
it('calls twice ReactDOM.render(<RefinementList props />, container)', () => {
59+
widget.render({helper});
60+
widget.render({helper});
61+
62+
let props = {
63+
cssClasses: {
64+
active: 'ais-refinement-list--item__active',
65+
body: 'ais-refinement-list--body',
66+
footer: 'ais-refinement-list--footer',
67+
header: 'ais-refinement-list--header',
68+
item: 'ais-refinement-list--item',
69+
label: 'ais-refinement-list--label',
70+
list: 'ais-refinement-list--list',
71+
radio: 'ais-refinement-list--radio',
72+
root: 'ais-refinement-list'
73+
},
74+
facetValues: [
75+
{attributeName: 'price', isRefined: true, name: 'All'},
76+
{attributeName: 'price', end: 4, isRefined: false, name: 'less than 4'},
77+
{attributeName: 'price', end: 4, isRefined: false, name: '4', start: 4},
78+
{attributeName: 'price', end: 10, isRefined: false, name: 'between 5 and 10', start: 5},
79+
{attributeName: 'price', isRefined: false, name: 'more than 10', start: 10}
80+
],
81+
createURL: () => {},
82+
toggleRefinement: () => {},
83+
shouldAutoHideContainer: false,
84+
templateProps: {
85+
templates: {footer: '', header: '', item: '<label class="{{cssClasses.label}}">\n <input type="radio" class="{{cssClasses.checkbox}}" name="{{attributeName}}" {{#isRefined}}checked{{/isRefined}} />{{name}}\n</label>'},
86+
templatesConfig: undefined,
87+
transformData: undefined,
88+
useCustomCompileOptions: {
89+
footer: false,
90+
header: false,
91+
item: false
92+
}
93+
}
94+
};
95+
96+
expect(ReactDOM.render.calledTwice).toBe(true, 'ReactDOM.render called twice');
97+
expect(autoHideContainer.calledOnce).toBe(true, 'autoHideContainer called once');
98+
expect(headerFooter.calledOnce).toBe(true, 'headerFooter called once');
99+
expect(ReactDOM.render.firstCall.args[0]).toEqualJSX(<RefinementList {...props} />);
100+
expect(ReactDOM.render.firstCall.args[1]).toEqual(container);
101+
expect(ReactDOM.render.secondCall.args[0]).toEqualJSX(<RefinementList {...props} />);
102+
expect(ReactDOM.render.secondCall.args[1]).toEqual(container);
103+
});
104+
105+
it('doesn\'t call the refinement functions if not refined', () => {
106+
widget.render({helper});
107+
expect(helper.state.clearRefinements.called).toBe(false, 'clearRefinements called one');
108+
expect(helper.state.addNumericRefinement.called).toBe(false, 'addNumericRefinement never called');
109+
expect(helper.search.called).toBe(false, 'search never called');
110+
});
111+
112+
it('calls the refinement functions if refined with "4"', () => {
113+
widget._toggleRefinement(helper, '4');
114+
expect(helper.state.clearRefinements.calledOnce).toBe(true, 'clearRefinements called once');
115+
expect(helper.state.addNumericRefinement.calledOnce).toBe(true, 'addNumericRefinement called once');
116+
expect(helper.state.addNumericRefinement.getCall(0).args).toEqual(['price', '=', 4]);
117+
expect(helper.search.calledOnce).toBe(true, 'search called once');
118+
});
119+
120+
it('calls the refinement functions if refined with "between 5 and 10"', () => {
121+
widget._toggleRefinement(helper, 'between 5 and 10');
122+
expect(helper.state.clearRefinements.calledOnce).toBe(true, 'clearRefinements called once');
123+
expect(helper.state.addNumericRefinement.calledTwice).toBe(true, 'addNumericRefinement called twice');
124+
expect(helper.state.addNumericRefinement.getCall(0).args).toEqual(['price', '>=', 5]);
125+
expect(helper.state.addNumericRefinement.getCall(1).args).toEqual(['price', '<=', 10]);
126+
expect(helper.search.calledOnce).toBe(true, 'search called once');
127+
});
128+
129+
it('calls two times the refinement functions if refined with "less than 4"', () => {
130+
widget._toggleRefinement(helper, 'less than 4');
131+
expect(helper.state.clearRefinements.calledOnce).toBe(true, 'clearRefinements called once');
132+
expect(helper.state.addNumericRefinement.calledOnce).toBe(true, 'addNumericRefinement called once');
133+
expect(helper.state.addNumericRefinement.getCall(0).args).toEqual(['price', '<=', 4]);
134+
expect(helper.search.calledOnce).toBe(true, 'search called once');
135+
});
136+
137+
it('calls two times the refinement functions if refined with "more than 10"', () => {
138+
widget._toggleRefinement(helper, 'more than 10');
139+
expect(helper.state.clearRefinements.calledOnce).toBe(true, 'clearRefinements called once');
140+
expect(helper.state.addNumericRefinement.calledOnce).toBe(true, 'addNumericRefinement called once');
141+
expect(helper.state.addNumericRefinement.getCall(0).args).toEqual(['price', '>=', 10]);
142+
expect(helper.search.calledOnce).toBe(true, 'search called once');
143+
});
144+
145+
afterEach(() => {
146+
numericRefinementList.__ResetDependency__('ReactDOM');
147+
numericRefinementList.__ResetDependency__('autoHideContainer');
148+
numericRefinementList.__ResetDependency__('headerFooter');
149+
});
150+
});
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
module.exports = {
2+
header: '',
3+
item: `<label class="{{cssClasses.label}}">
4+
<input type="radio" class="{{cssClasses.checkbox}}" name="{{attributeName}}" {{#isRefined}}checked{{/isRefined}} />{{name}}
5+
</label>`,
6+
footer: ''
7+
};

0 commit comments

Comments
 (0)