Skip to content

Commit e5fe344

Browse files
redoxvvo
authored and
vvo
committed
feat(priceRanges): new Amazon-style price ranges widget
1 parent 873f503 commit e5fe344

File tree

12 files changed

+575
-0
lines changed

12 files changed

+575
-0
lines changed

components/PriceRanges.js

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
var React = require('react');
2+
3+
var Template = require('./Template');
4+
var cx = require('classnames');
5+
6+
class PriceRange extends React.Component {
7+
refine(from, to, event) {
8+
event.preventDefault();
9+
this.refs.from.value = this.refs.to.value = '';
10+
this.props.refine(from, to);
11+
}
12+
13+
render() {
14+
return (
15+
<div className={this.props.cssClasses.root}>
16+
{this.props.facetValues.map(facetValue => {
17+
var key = facetValue.from + '_' + facetValue.to;
18+
return (
19+
<a
20+
className={cx(this.props.cssClasses.range, {active: facetValue.isRefined})}
21+
href="#"
22+
key={key}
23+
onClick={this.refine.bind(this, facetValue.from, facetValue.to)}
24+
>
25+
<Template data={facetValue} templateKey="range" {...this.props.templateProps} />
26+
</a>
27+
);
28+
})}
29+
<div className={this.props.cssClasses.inputGroup}>
30+
<label>
31+
{this.props.labels.currency}{' '}
32+
<input className={this.props.cssClasses.input} ref="from" type="number" />
33+
</label>
34+
{' '}{this.props.labels.to}{' '}
35+
<label>
36+
{this.props.labels.currency}{' '}
37+
<input className={this.props.cssClasses.input} ref="to" type="number" />
38+
</label>
39+
{' '}
40+
<button
41+
className={this.props.cssClasses.button}
42+
onClick={(e) => {
43+
this.refine(+this.refs.from.value || undefined, +this.refs.to.value || undefined, e);
44+
}}
45+
>{this.props.labels.button}</button>
46+
</div>
47+
</div>
48+
);
49+
}
50+
}
51+
52+
PriceRange.propTypes = {
53+
cssClasses: React.PropTypes.shape({
54+
root: React.PropTypes.oneOfType([
55+
React.PropTypes.string,
56+
React.PropTypes.arrayOf(React.PropTypes.string)
57+
]),
58+
range: React.PropTypes.oneOfType([
59+
React.PropTypes.string,
60+
React.PropTypes.arrayOf(React.PropTypes.string)
61+
]),
62+
input: React.PropTypes.oneOfType([
63+
React.PropTypes.string,
64+
React.PropTypes.arrayOf(React.PropTypes.string)
65+
]),
66+
button: React.PropTypes.oneOfType([
67+
React.PropTypes.string,
68+
React.PropTypes.arrayOf(React.PropTypes.string)
69+
])
70+
}),
71+
facetValues: React.PropTypes.array,
72+
labels: React.PropTypes.shape({
73+
button: React.PropTypes.string,
74+
currency: React.PropTypes.string,
75+
to: React.PropTypes.string
76+
}),
77+
refine: React.PropTypes.func.isRequired,
78+
templateProps: React.PropTypes.object.isRequired
79+
};
80+
81+
module.exports = PriceRange;
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
/* eslint-env mocha */
2+
3+
import React from 'react';
4+
import expect from 'expect';
5+
import TestUtils from 'react-addons-test-utils';
6+
import PriceRanges from '../PriceRanges';
7+
import generateRanges from '../../widgets/price-ranges/generate-ranges.js';
8+
9+
describe('PriceRanges', () => {
10+
var renderer;
11+
12+
beforeEach(() => {
13+
let {createRenderer} = TestUtils;
14+
renderer = createRenderer();
15+
});
16+
17+
context('with stats', () => {
18+
var out;
19+
var facetValues;
20+
21+
beforeEach(() => {
22+
facetValues = generateRanges({
23+
min: 1.99,
24+
max: 4999.98,
25+
avg: 243.349,
26+
sum: 2433490.0
27+
});
28+
29+
var props = {
30+
templateProps: {},
31+
facetValues,
32+
cssClasses: {
33+
root: 'root-class',
34+
range: 'range-class',
35+
inputGroup: 'input-group-class',
36+
button: 'button-class',
37+
input: 'input-class'
38+
},
39+
labels: {
40+
currency: 'USD',
41+
to: 'to',
42+
button: 'Go'
43+
},
44+
refine: () => {}
45+
};
46+
47+
renderer.render(<PriceRanges {...props} />);
48+
out = renderer.getRenderOutput();
49+
});
50+
51+
it('should add the root class', () => {
52+
expect(out.type).toBe('div');
53+
expect(out.props.className).toEqual('root-class');
54+
});
55+
56+
it('should have the right number of children', () => {
57+
expect(out.props.children.length).toEqual(2);
58+
expect(out.props.children[0].length).toEqual(facetValues.length);
59+
});
60+
61+
it('should have the range class', () => {
62+
out.props.children[0].forEach((c) => {
63+
expect(c.props.className).toEqual('range-class');
64+
});
65+
});
66+
67+
it('should have the input group class', () => {
68+
expect(out.props.children.length).toEqual(2);
69+
expect(out.props.children[1].props.className).toEqual('input-group-class');
70+
});
71+
72+
it('should display the inputs with the associated class & labels', () => {
73+
expect(out.props.children.length).toEqual(2);
74+
var click = out.props.children[1].props.children[6].props.onClick;
75+
expect(out.props.children[1]).toEqual(
76+
<div className="input-group-class">
77+
<label>
78+
USD{' '}<input className="input-class" ref="from" type="number" />
79+
</label>
80+
{' '}to{' '}
81+
<label>
82+
USD{' '}<input className="input-class" ref="to" type="number" />
83+
</label>
84+
{' '}
85+
<button className="button-class" onClick={click}>Go</button>
86+
</div>
87+
);
88+
});
89+
});
90+
});

example/app.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,8 +167,24 @@ search.addWidget(
167167
})
168168
);
169169

170+
170171
search.once('render', function() {
171172
document.querySelector('.search').className = 'row search search--visible';
172173
});
173174

175+
search.addWidget(
176+
instantsearch.widgets.priceRanges({
177+
container: '#price_ranges',
178+
facetName: 'price',
179+
cssClasses: {
180+
root: 'nav nav-stacked',
181+
range: 'list-group-item',
182+
inputGroup: 'list-group-item form-inline',
183+
input: 'form-control input-sm fixed-input-sm',
184+
button: 'btn btn-default btn-sm'
185+
},
186+
template: require('./templates/price_range.html')
187+
})
188+
);
189+
174190
search.start();

example/index.html

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,11 @@ <h1>Instant search demo <small>using instantsearch.js</small></h1>
3333

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

36+
<div class="panel panel-default">
37+
<div class="panel-heading">Price</div>
38+
<div id="price_ranges" class="list-group"></div>
39+
</div>
40+
3641
</div>
3742
<div class="col-md-9">
3843
<div class="form-group">

example/style.css

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,3 +80,7 @@ body {
8080
.ais-toggle--label {
8181
display: block;
8282
}
83+
84+
.fixed-input-sm {
85+
width: 65px !important;
86+
}

example/templates/price_range.html

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<a href="#" class="list-group-item{{#isRefined}} active{{/isRefined}}">
2+
{{#from}}
3+
{{^to}}
4+
&gt;
5+
{{/to}}
6+
${{from}}
7+
{{/from}}
8+
{{#to}}
9+
{{#from}}
10+
-
11+
{{/from}}
12+
{{^from}}
13+
&lt;
14+
{{/from}}
15+
${{to}}
16+
{{/to}}
17+
<span>{{count}}</span>
18+
</a>

index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ instantsearch.widgets = {
1414
menu: require('./widgets/menu/menu.js'),
1515
refinementList: require('./widgets/refinement-list/refinement-list.js'),
1616
pagination: require('./widgets/pagination/pagination'),
17+
priceRanges: require('./widgets/price-ranges/price-ranges.js'),
1718
searchBox: require('./widgets/search-box'),
1819
rangeSlider: require('./widgets/range-slider/range-slider'),
1920
stats: require('./widgets/stats/stats'),
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/* eslint-env mocha */
2+
3+
import React from 'react';
4+
import expect from 'expect';
5+
6+
import generateRanges from '../generate-ranges';
7+
8+
describe('generateRanges()', () => {
9+
it('should generate ranges', () => {
10+
var stats = {
11+
min: 1.99,
12+
max: 4999.98,
13+
avg: 243.349,
14+
sum: 2433490.0
15+
};
16+
var expected = [
17+
{to: 1},
18+
{from: 1, to: 80},
19+
{from: 80, to: 160},
20+
{from: 160, to: 240},
21+
{from: 240, to: 1820},
22+
{from: 1820, to: 3400},
23+
{from: 3400, to: 4980},
24+
{from: 4980}
25+
];
26+
expect(generateRanges(stats)).toEqual(expected);
27+
});
28+
29+
it('should generate small ranges', () => {
30+
var stats = {min: 20, max: 50, avg: 35, sum: 70};
31+
var expected = [
32+
{to: 20},
33+
{from: 20, to: 25},
34+
{from: 25, to: 30},
35+
{from: 30, to: 35},
36+
{from: 35, to: 40},
37+
{from: 40, to: 45},
38+
{from: 45}
39+
];
40+
expect(generateRanges(stats)).toEqual(expected);
41+
});
42+
43+
it('should not generate ranges', () => {
44+
var stats = {min: 20, max: 20, avg: 20, sum: 20};
45+
expect(generateRanges(stats)).toEqual([]);
46+
});
47+
});

0 commit comments

Comments
 (0)