Skip to content

Commit b9e20f3

Browse files
authored
feat(widget): Search for facet values - refinement list (#1753)
This adds options for the refinement list to support search for facet values. This means that if activated, the widget will display a search input to look for facet values that are not in the list. The results are replaced directly in the list of options.
1 parent efba789 commit b9e20f3

File tree

13 files changed

+907
-483
lines changed

13 files changed

+907
-483
lines changed

.eslintignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
dist/
22
dist-es5-module/
33
docs/
4+
node_modules/

dev/app.js

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,30 @@ search.addWidget(
161161
})
162162
);
163163

164+
search.addWidget(
165+
instantsearch.widgets.refinementList({
166+
container: '#searchable-brands',
167+
attributeName: 'brand',
168+
operator: 'or',
169+
limit: 10,
170+
cssClasses: {
171+
header: 'facet-title',
172+
item: 'facet-value checkbox',
173+
count: 'facet-count pull-right',
174+
active: 'facet-active',
175+
},
176+
templates: {
177+
header: 'Searchable brands',
178+
},
179+
searchForFacetValues: {
180+
placeholder: 'Find other brands...',
181+
templates: {
182+
noResults: 'No results',
183+
},
184+
},
185+
})
186+
);
187+
164188
search.addWidget(
165189
instantsearch.widgets.refinementList({
166190
collapsible: {

dev/index.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ <h1><a href="./">Instant search demo</a> <small>using instantsearch.js</small></
2222
<div class="col-md-3 smooth-search smooth-search--hidden">
2323
<div class="facet" id="current-refined-values"></div>
2424
<div class="facet" id="hierarchical-categories"></div>
25+
<div class="facet" id="searchable-brands"></div>
2526
<div class="facet" id="brands"></div>
2627
<div class="facet" id="brands-2"></div>
2728
<div class="facet" id="price-range"></div>

scripts/staging-deploy.sh

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,5 +15,3 @@ rm ./dev/instantsearch.css
1515
mv ./dev/instantsearch.css.symlink ./dev/instantsearch.css
1616
rm ./dev/bundle.js
1717
rm ./dev/bundle.js.map
18-
rm ./dev/instantsearch.js
19-
rm ./dev/instantsearch.js.map

src/components/RefinementList/RefinementList.js

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import Template from '../Template.js';
66
import RefinementListItem from './RefinementListItem.js';
77
import isEqual from 'lodash/isEqual';
88

9+
import SearchBox from '../SearchBox';
10+
911
class RefinementList extends React.Component {
1012
constructor(props) {
1113
super(props);
@@ -121,6 +123,20 @@ class RefinementList extends React.Component {
121123
this.setState({isShowMoreOpen});
122124
}
123125

126+
componentWillReceiveProps(nextProps) {
127+
if (this.searchbox && !nextProps.isFromSearch) {
128+
this.searchbox.clearInput();
129+
}
130+
}
131+
132+
refineFirstValue() {
133+
const firstValue = this.props.facetValues[0];
134+
if (firstValue) {
135+
const actualValue = firstValue[this.props.attributeNameKey];
136+
this.props.toggleRefinement(actualValue);
137+
}
138+
}
139+
124140
render() {
125141
// Adding `-lvl0` classes
126142
const cssClassList = [this.props.cssClasses.list];
@@ -144,9 +160,25 @@ class RefinementList extends React.Component {
144160
/> :
145161
undefined;
146162

163+
const searchInput = this.props.searchFacetValues ?
164+
<SearchBox ref={i => { this.searchbox = i; }}
165+
placeholder={this.props.searchPlaceholder}
166+
onChange={this.props.searchFacetValues}
167+
onValidate={() => this.refineFirstValue()}/> :
168+
null;
169+
170+
const noResults = this.props.searchFacetValues && this.props.isFromSearch && this.props.facetValues.length === 0 ?
171+
<Template
172+
templateKey={'noResults'}
173+
{...this.props.templateProps}
174+
/> :
175+
null;
176+
147177
return (
148178
<div className={cx(cssClassList)}>
179+
{searchInput}
149180
{displayedFacetValues.map(this._generateFacetItem, this)}
181+
{noResults}
150182
{showMoreBtn}
151183
</div>
152184
);
@@ -170,6 +202,9 @@ RefinementList.propTypes = {
170202
showMore: React.PropTypes.bool,
171203
templateProps: React.PropTypes.object.isRequired,
172204
toggleRefinement: React.PropTypes.func.isRequired,
205+
searchFacetValues: React.PropTypes.func,
206+
searchPlaceholder: React.PropTypes.string,
207+
isFromSearch: React.PropTypes.bool,
173208
};
174209

175210
RefinementList.defaultProps = {

src/components/SearchBox/index.js

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
/* eslint-disable max-len, no-extra-parens */
2+
import React from 'react';
3+
4+
export default class SearchBox extends React.Component {
5+
static propTypes = {
6+
placeholder: React.PropTypes.string.isRequired,
7+
onChange: React.PropTypes.func.isRequired,
8+
onValidate: React.PropTypes.func.isRequired,
9+
}
10+
11+
clearInput() {
12+
if (this.input) {
13+
this.input.value = '';
14+
}
15+
}
16+
17+
validateSearch(e) {
18+
e.preventDefault();
19+
if (this.input) {
20+
const inputValue = this.input.value;
21+
if (inputValue) this.props.onValidate();
22+
}
23+
}
24+
25+
render() {
26+
const {placeholder, onChange} = this.props;
27+
28+
return (
29+
<form noValidate="novalidate"
30+
className="searchbox sbx-custom"
31+
onReset={() => { onChange(''); }}
32+
onSubmit={e => this.validateSearch(e) }
33+
>
34+
<svg xmlns="http://www.w3.org/2000/svg" style={{display: 'none'}}>
35+
<symbol xmlns="http://www.w3.org/2000/svg" id="sbx-icon-search-12" viewBox="0 0 40 41">
36+
<path d="M30.967 27.727l-.03-.03c-.778-.777-2.038-.777-2.815 0l-1.21 1.21c-.78.78-.778 2.04 0 2.817l.03.03 4.025-4.027zm1.083 1.084L39.24 36c.778.778.78 2.037 0 2.816l-1.21 1.21c-.777.778-2.038.78-2.816 0l-7.19-7.19 4.026-4.025zM15.724 31.45c8.684 0 15.724-7.04 15.724-15.724C31.448 7.04 24.408 0 15.724 0 7.04 0 0 7.04 0 15.724c0 8.684 7.04 15.724 15.724 15.724zm0-3.93c6.513 0 11.793-5.28 11.793-11.794 0-6.513-5.28-11.793-11.793-11.793C9.21 3.93 3.93 9.21 3.93 15.725c0 6.513 5.28 11.793 11.794 11.793z"
37+
fillRule="evenodd" />
38+
</symbol>
39+
<symbol xmlns="http://www.w3.org/2000/svg" id="sbx-icon-clear-2" viewBox="0 0 20 20">
40+
<path d="M8.96 10L.52 1.562 0 1.042 1.04 0l.522.52L10 8.96 18.438.52l.52-.52L20 1.04l-.52.522L11.04 10l8.44 8.438.52.52L18.96 20l-.522-.52L10 11.04l-8.438 8.44-.52.52L0 18.96l.52-.522L8.96 10z" fillRule="evenodd" />
41+
</symbol>
42+
</svg>
43+
44+
<div role="search" className="sbx-custom__wrapper">
45+
<input type="search" name="search" placeholder={placeholder} autoComplete="off" required="required" className="sbx-custom__input" onChange={e => onChange(e.target.value)} ref={i => { this.input = i; }} />
46+
<button type="submit" title="Submit your search query." className="sbx-custom__submit">
47+
<svg role="img" aria-label="Search">
48+
<use xlinkHref="#sbx-icon-search-12"></use>
49+
</svg>
50+
</button>
51+
<button type="reset" title="Clear the search query." className="sbx-custom__reset">
52+
<svg role="img" aria-label="Reset">
53+
<use xlinkHref="#sbx-icon-clear-2"></use>
54+
</svg>
55+
</button>
56+
</div>
57+
</form>
58+
);
59+
}
60+
}

src/components/Template.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import hogan from 'hogan.js';
88

99
import isEqual from 'lodash/isEqual';
1010

11-
export class Template extends React.Component {
11+
export class PureTemplate extends React.Component {
1212
shouldComponentUpdate(nextProps) {
1313
return !isEqual(this.props.data, nextProps.data) || this.props.templateKey !== nextProps.templateKey;
1414
}
@@ -39,7 +39,7 @@ export class Template extends React.Component {
3939
}
4040
}
4141

42-
Template.propTypes = {
42+
PureTemplate.propTypes = {
4343
data: React.PropTypes.object,
4444
rootProps: React.PropTypes.object,
4545
templateKey: React.PropTypes.string,
@@ -67,7 +67,7 @@ Template.propTypes = {
6767
useCustomCompileOptions: React.PropTypes.objectOf(React.PropTypes.bool),
6868
};
6969

70-
Template.defaultProps = {
70+
PureTemplate.defaultProps = {
7171
data: {},
7272
useCustomCompileOptions: {},
7373
templates: {},
@@ -150,4 +150,4 @@ const withTransformData =
150150
/>;
151151
};
152152

153-
export default withTransformData(Template);
153+
export default withTransformData(PureTemplate);

src/components/__tests__/Template-test.js

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import React from 'react';
44
import ReactDOM from 'react-dom';
55
import expect from 'expect';
66
import TestUtils from 'react-addons-test-utils';
7-
import TemplateWithTransformData, {Template} from '../Template';
7+
import TemplateWithTransformData, {PureTemplate} from '../Template';
88
import sinon from 'sinon';
99
import expectJSX from 'expect-jsx';
1010
expect.extend(expectJSX);
@@ -25,7 +25,7 @@ describe('Template', () => {
2525
data: {type: 'strings'},
2626
});
2727

28-
renderer.render(<Template {...props} />);
28+
renderer.render(<PureTemplate {...props} />);
2929
const out = renderer.getRenderOutput();
3030

3131
const content = 'it works with strings';
@@ -38,7 +38,7 @@ describe('Template', () => {
3838
data: {type: 'functions'},
3939
});
4040

41-
renderer.render(<Template {...props} />);
41+
renderer.render(<PureTemplate {...props} />);
4242
const out = renderer.getRenderOutput();
4343

4444
const content = 'it also works with functions';
@@ -51,7 +51,7 @@ describe('Template', () => {
5151
data: {type: 'functions'},
5252
});
5353

54-
renderer.render(<Template {...props} />);
54+
renderer.render(<PureTemplate {...props} />);
5555
const out = renderer.getRenderOutput();
5656

5757
const content = 'it also works with functions';
@@ -66,7 +66,7 @@ describe('Template', () => {
6666
templatesConfig: {compileOptions: {delimiters: '<% %>'}},
6767
});
6868

69-
renderer.render(<Template {...props} />);
69+
renderer.render(<PureTemplate {...props} />);
7070
const out = renderer.getRenderOutput();
7171

7272
const content = 'it configures compilation delimiters';
@@ -82,7 +82,7 @@ describe('Template', () => {
8282
templatesConfig: {helpers: {emphasis: (text, render) => `<em>${render(text)}</em>`}},
8383
});
8484

85-
renderer.render(<Template {...props} />);
85+
renderer.render(<PureTemplate {...props} />);
8686
const out = renderer.getRenderOutput();
8787

8888
const content = 'it supports <em>helpers</em>';
@@ -105,7 +105,7 @@ describe('Template', () => {
105105
},
106106
});
107107

108-
renderer.render(<Template {...props} />);
108+
renderer.render(<PureTemplate {...props} />);
109109
});
110110
});
111111

@@ -123,7 +123,7 @@ describe('Template', () => {
123123
renderer.render(<TemplateWithTransformData {...props} />);
124124

125125
const out = renderer.getRenderOutput();
126-
const expectedJSX = <Template {...props} data={{feature: 'transformData'}} />;
126+
const expectedJSX = <PureTemplate {...props} data={{feature: 'transformData'}} />;
127127

128128
expect(out).toEqualJSX(expectedJSX);
129129
});
@@ -140,7 +140,7 @@ describe('Template', () => {
140140
renderer.render(<TemplateWithTransformData {...props} />);
141141

142142
const out = renderer.getRenderOutput();
143-
const expectedJSX = <Template {...props} data={{test: 'transformData'}} />;
143+
const expectedJSX = <PureTemplate {...props} data={{test: 'transformData'}} />;
144144

145145
expect(out).toEqualJSX(expectedJSX);
146146
});
@@ -228,7 +228,7 @@ describe('Template', () => {
228228
function fn() {}
229229

230230
const props = getProps({});
231-
renderer.render(<Template rootProps={{className: 'hey', onClick: fn}} {...props}/>);
231+
renderer.render(<PureTemplate rootProps={{className: 'hey', onClick: fn}} {...props}/>);
232232

233233
const out = renderer.getRenderOutput();
234234
const expectedProps = {
@@ -249,25 +249,25 @@ describe('Template', () => {
249249
props = getProps({
250250
data: {hello: 'mom'},
251251
});
252-
component = ReactDOM.render(<Template {...props} />, container);
252+
component = ReactDOM.render(<PureTemplate {...props} />, container);
253253
sinon.spy(component, 'render');
254254
});
255255

256256
it('does not call render when no change in data', () => {
257-
ReactDOM.render(<Template {...props} />, container);
257+
ReactDOM.render(<PureTemplate {...props} />, container);
258258
expect(component.render.called).toBe(false);
259259
});
260260

261261
it('calls render when data changes', () => {
262262
props.data = {hello: 'dad'};
263-
ReactDOM.render(<Template {...props} />, container);
263+
ReactDOM.render(<PureTemplate {...props} />, container);
264264
expect(component.render.called).toBe(true);
265265
});
266266

267267
it('calls render when templateKey changes', () => {
268268
props.templateKey += '-rerender';
269269
props.templates = {[props.templateKey]: ''};
270-
ReactDOM.render(<Template {...props} />, container);
270+
ReactDOM.render(<PureTemplate {...props} />, container);
271271
expect(component.render.called).toBe(true);
272272
});
273273
});

0 commit comments

Comments
 (0)