Skip to content

Commit 918d971

Browse files
author
Alexandre Stanislawski
committed
feat(connector): numericRefinementList connector
1 parent 77083b7 commit 918d971

File tree

6 files changed

+244
-191
lines changed

6 files changed

+244
-191
lines changed

dev/app.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -277,7 +277,7 @@ search.addWidget(
277277
active: 'facet-active',
278278
},
279279
templates: {
280-
header: 'Price numeric list',
280+
header: 'Numeric refinement list (price)',
281281
},
282282
})
283283
);

src/connectors/menu/connectMenu.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ const menu = menuRendering => ({
161161
facetValues,
162162
limitMax: widgetMaxValuesPerFacet,
163163
limitMin: limit,
164-
shouldAutoHideContainer: facetValues.length === 0,
164+
shouldAutoHideContainer: autoHideContainer && facetValues.length === 0,
165165
showMore: showMoreConfig !== null,
166166
templateProps: this._templateProps,
167167
toggleRefinement: this._toggleRefinement,
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
import {
2+
bemHelper,
3+
prepareTemplateProps,
4+
getContainerNode,
5+
} from '../../lib/utils.js';
6+
import cx from 'classnames';
7+
import find from 'lodash/find';
8+
import includes from 'lodash/includes';
9+
import defaultTemplates from './defaultTemplates.js';
10+
11+
const bem = bemHelper('ais-refinement-list');
12+
13+
/**
14+
* Instantiate a list of refinements based on a facet
15+
* @function numericRefinementList
16+
* @param {string|DOMElement} options.container CSS Selector or DOMElement to insert the widget
17+
* @param {string} options.attributeName Name of the attribute for filtering
18+
* @param {Object[]} options.options List of all the options
19+
* @param {string} options.options[].name Name of the option
20+
* @param {number} [options.options[].start] Low bound of the option (>=)
21+
* @param {number} [options.options[].end] High bound of the option (<=)
22+
* @param {Object} [options.templates] Templates to use for the widget
23+
* @param {string|Function} [options.templates.header] Header template
24+
* @param {string|Function} [options.templates.item] Item template, provided with `name`, `isRefined`, `url` data properties
25+
* @param {string|Function} [options.templates.footer] Footer template
26+
* @param {Function} [options.transformData.item] Function to change the object passed to the `item` template
27+
* @param {boolean} [options.autoHideContainer=true] Hide the container when no results match
28+
* @param {Object} [options.cssClasses] CSS classes to add to the wrapping elements
29+
* @param {string|string[]} [options.cssClasses.root] CSS class to add to the root element
30+
* @param {string|string[]} [options.cssClasses.header] CSS class to add to the header element
31+
* @param {string|string[]} [options.cssClasses.body] CSS class to add to the body element
32+
* @param {string|string[]} [options.cssClasses.footer] CSS class to add to the footer element
33+
* @param {string|string[]} [options.cssClasses.list] CSS class to add to the list element
34+
* @param {string|string[]} [options.cssClasses.label] CSS class to add to each link element
35+
* @param {string|string[]} [options.cssClasses.item] CSS class to add to each item element
36+
* @param {string|string[]} [options.cssClasses.radio] CSS class to add to each radio element (when using the default template)
37+
* @param {string|string[]} [options.cssClasses.active] CSS class to add to each active element
38+
* @param {object|boolean} [options.collapsible=false] Hide the widget body and footer when clicking on header
39+
* @param {boolean} [options.collapsible.collapsed] Initial collapsed state of a collapsible widget
40+
* @return {Object}
41+
*/
42+
const usage = `Usage:
43+
numericRefinementList({
44+
container,
45+
attributeName,
46+
options,
47+
[ cssClasses.{root,header,body,footer,list,item,active,label,radio,count} ],
48+
[ templates.{header,item,footer} ],
49+
[ transformData.{item} ],
50+
[ autoHideContainer ],
51+
[ collapsible=false ]
52+
})`;
53+
const connectNumericRefinementList = numericRefinementListRendering => ({
54+
container,
55+
attributeName,
56+
options,
57+
cssClasses: userCssClasses = {},
58+
templates = defaultTemplates,
59+
collapsible = false,
60+
transformData,
61+
autoHideContainer = true,
62+
}) => {
63+
if (!container || !attributeName || !options) {
64+
throw new Error(usage);
65+
}
66+
67+
const containerNode = getContainerNode(container);
68+
69+
const cssClasses = {
70+
root: cx(bem(null), userCssClasses.root),
71+
header: cx(bem('header'), userCssClasses.header),
72+
body: cx(bem('body'), userCssClasses.body),
73+
footer: cx(bem('footer'), userCssClasses.footer),
74+
list: cx(bem('list'), userCssClasses.list),
75+
item: cx(bem('item'), userCssClasses.item),
76+
label: cx(bem('label'), userCssClasses.label),
77+
radio: cx(bem('radio'), userCssClasses.radio),
78+
active: cx(bem('item', 'active'), userCssClasses.active),
79+
};
80+
81+
return {
82+
init({templatesConfig, helper, createURL}) {
83+
this._templateProps = prepareTemplateProps({
84+
transformData,
85+
defaultTemplates,
86+
templatesConfig,
87+
templates,
88+
});
89+
90+
this._toggleRefinement = facetValue => {
91+
const refinedState = refine(helper.state, attributeName, options, facetValue);
92+
helper.setState(refinedState).search();
93+
};
94+
95+
this._createURL = state => facetValue => createURL(refine(state, attributeName, options, facetValue));
96+
97+
numericRefinementListRendering({
98+
collapsible,
99+
createURL: this._createURL(helper.state),
100+
cssClasses,
101+
facetValues: [],
102+
shouldAutoHideContainer: autoHideContainer,
103+
templateProps: this._templateProps,
104+
toggleRefinement: this._toggleRefinement,
105+
containerNode,
106+
}, true);
107+
},
108+
render({results, state}) {
109+
const facetValues = options.map(facetValue =>
110+
({
111+
...facetValue,
112+
isRefined: isRefined(state, attributeName, facetValue),
113+
attributeName,
114+
})
115+
);
116+
117+
numericRefinementListRendering({
118+
collapsible,
119+
createURL: this._createURL(state),
120+
cssClasses,
121+
facetValues,
122+
shouldAutoHideContainer: autoHideContainer && results.nbHits === 0,
123+
templateProps: this._templateProps,
124+
toggleRefinement: this._toggleRefinement,
125+
containerNode,
126+
}, false);
127+
},
128+
};
129+
};
130+
131+
function isRefined(state, attributeName, option) {
132+
const currentRefinements = state.getNumericRefinements(attributeName);
133+
134+
if (option.start !== undefined && option.end !== undefined) {
135+
if (option.start === option.end) {
136+
return hasNumericRefinement(currentRefinements, '=', option.start);
137+
}
138+
}
139+
140+
if (option.start !== undefined) {
141+
return hasNumericRefinement(currentRefinements, '>=', option.start);
142+
}
143+
144+
if (option.end !== undefined) {
145+
return hasNumericRefinement(currentRefinements, '<=', option.end);
146+
}
147+
148+
if (option.start === undefined && option.end === undefined) {
149+
return Object.keys(currentRefinements).length === 0;
150+
}
151+
152+
return undefined;
153+
}
154+
155+
function refine(state, attributeName, options, facetValue) {
156+
let resolvedState = state;
157+
158+
const refinedOption = find(options, {name: facetValue});
159+
160+
const currentRefinements = resolvedState.getNumericRefinements(attributeName);
161+
162+
if (refinedOption.start === undefined && refinedOption.end === undefined) {
163+
return resolvedState.clearRefinements(attributeName);
164+
}
165+
166+
if (!isRefined(resolvedState, attributeName, refinedOption)) {
167+
resolvedState = resolvedState.clearRefinements(attributeName);
168+
}
169+
170+
if (refinedOption.start !== undefined && refinedOption.end !== undefined) {
171+
if (refinedOption.start > refinedOption.end) {
172+
throw new Error('option.start should be > to option.end');
173+
}
174+
175+
if (refinedOption.start === refinedOption.end) {
176+
if (hasNumericRefinement(currentRefinements, '=', refinedOption.start)) {
177+
resolvedState = resolvedState.removeNumericRefinement(attributeName, '=', refinedOption.start);
178+
} else {
179+
resolvedState = resolvedState.addNumericRefinement(attributeName, '=', refinedOption.start);
180+
}
181+
return resolvedState;
182+
}
183+
}
184+
185+
if (refinedOption.start !== undefined) {
186+
if (hasNumericRefinement(currentRefinements, '>=', refinedOption.start)) {
187+
resolvedState = resolvedState.removeNumericRefinement(attributeName, '>=', refinedOption.start);
188+
} else {
189+
resolvedState = resolvedState.addNumericRefinement(attributeName, '>=', refinedOption.start);
190+
}
191+
}
192+
193+
if (refinedOption.end !== undefined) {
194+
if (hasNumericRefinement(currentRefinements, '<=', refinedOption.end)) {
195+
resolvedState = resolvedState.removeNumericRefinement(attributeName, '<=', refinedOption.end);
196+
} else {
197+
resolvedState = resolvedState.addNumericRefinement(attributeName, '<=', refinedOption.end);
198+
}
199+
}
200+
201+
return resolvedState;
202+
}
203+
204+
function hasNumericRefinement(currentRefinements, operator, value) {
205+
const hasOperatorRefinements = currentRefinements[operator] !== undefined;
206+
const includesValue = includes(currentRefinements[operator], value);
207+
208+
return hasOperatorRefinements && includesValue;
209+
}
210+
211+
export default connectNumericRefinementList;

src/widgets/numeric-refinement-list/defaultTemplates.js renamed to src/connectors/numeric-refinement-list/defaultTemplates.js

File renamed without changes.

src/widgets/numeric-refinement-list/__tests__/numeric-refinement-list-test.js

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,6 @@ describe('numericRefinementList()', () => {
3737
let widget;
3838
let helper;
3939

40-
let autoHideContainer;
41-
let headerFooter;
4240
let options;
4341
let results;
4442
let createURL;
@@ -47,10 +45,6 @@ describe('numericRefinementList()', () => {
4745
beforeEach(() => {
4846
ReactDOM = {render: sinon.spy()};
4947
numericRefinementList.__Rewire__('ReactDOM', ReactDOM);
50-
autoHideContainer = sinon.stub().returns(RefinementList);
51-
numericRefinementList.__Rewire__('autoHideContainerHOC', autoHideContainer);
52-
headerFooter = sinon.stub().returns(RefinementList);
53-
numericRefinementList.__Rewire__('headerFooterHOC', headerFooter);
5448

5549
options = [
5650
{name: 'All'},
@@ -134,9 +128,7 @@ describe('numericRefinementList()', () => {
134128
},
135129
};
136130

137-
expect(ReactDOM.render.calledTwice).toBe(true, 'ReactDOM.render called twice');
138-
expect(autoHideContainer.calledOnce).toBe(true, 'autoHideContainer called once');
139-
expect(headerFooter.calledOnce).toBe(true, 'headerFooter called once');
131+
expect(ReactDOM.render.callCount).toBe(2);
140132
expect(ReactDOM.render.firstCall.args[0]).toEqualJSX(<RefinementList {...props} />);
141133
expect(ReactDOM.render.firstCall.args[1]).toEqual(container);
142134
expect(ReactDOM.render.secondCall.args[0]).toEqualJSX(<RefinementList {...props} />);
@@ -197,6 +189,9 @@ describe('numericRefinementList()', () => {
197189
options: initialOptions,
198190
});
199191

192+
// The lifeccycle impose all the steps
193+
testWidget.init({helper, createURL: () => ''});
194+
200195
// When
201196
testWidget.render({state, results, createURL});
202197

0 commit comments

Comments
 (0)