Skip to content

Commit

Permalink
feat(connector): add range-slider
Browse files Browse the repository at this point in the history
  • Loading branch information
Alexandre Stanislawski committed Feb 15, 2017
1 parent d8bed96 commit 1a02798
Show file tree
Hide file tree
Showing 5 changed files with 259 additions and 196 deletions.
9 changes: 6 additions & 3 deletions src/components/Slider/Slider.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ const cssPrefix = 'ais-range-slider--';

import isEqual from 'lodash/isEqual';

class Slider extends React.Component {
import autoHideContainerHOC from '../../decorators/autoHideContainer.js';
import headerFooterHOC from '../../decorators/headerFooter.js';

export class RawSlider extends React.Component {
componentWillMount() {
this.handleChange = this.handleChange.bind(this);
}
Expand Down Expand Up @@ -60,7 +63,7 @@ class Slider extends React.Component {
}
}

Slider.propTypes = {
RawSlider.propTypes = {
onChange: React.PropTypes.func,
onSlide: React.PropTypes.func,
pips: React.PropTypes.oneOfType([
Expand All @@ -79,4 +82,4 @@ Slider.propTypes = {
]),
};

export default Slider;
export default autoHideContainerHOC(headerFooterHOC(RawSlider));
2 changes: 1 addition & 1 deletion src/components/Slider/__tests__/Slider-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import expect from 'expect';
import TestUtils from 'react-addons-test-utils';

import expectJSX from 'expect-jsx';
import Slider from '../Slider';
import {RawSlider as Slider} from '../Slider';
import Nouislider from 'react-nouislider';
expect.extend(expectJSX);

Expand Down
211 changes: 211 additions & 0 deletions src/connectors/range-slider/connectRangeSlider.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
import {
bemHelper,
prepareTemplateProps,
getContainerNode,
} from '../../lib/utils.js';
import find from 'lodash/find';
import cx from 'classnames';

const bem = bemHelper('ais-range-slider');
const defaultTemplates = {
header: '',
footer: '',
};

/**
* Instantiate a slider based on a numeric attribute.
* This is a wrapper around [noUiSlider](http://refreshless.com/nouislider/)
* @function rangeSlider
* @param {string|DOMElement} options.container CSS Selector or DOMElement to insert the widget
* @param {string} options.attributeName Name of the attribute for faceting
* @param {boolean|Object} [options.tooltips=true] Should we show tooltips or not.
* The default tooltip will show the raw value.
* You can also provide
* `tooltips: {format: function(rawValue) {return '$' + Math.round(rawValue).toLocaleString()}}`
* So that you can format the tooltip display value as you want
* @param {Object} [options.templates] Templates to use for the widget
* @param {string|Function} [options.templates.header=''] Header template
* @param {string|Function} [options.templates.footer=''] Footer template
* @param {boolean} [options.autoHideContainer=true] Hide the container when no refinements available
* @param {Object} [options.cssClasses] CSS classes to add to the wrapping elements
* @param {string|string[]} [options.cssClasses.root] CSS class to add to the root element
* @param {string|string[]} [options.cssClasses.header] CSS class to add to the header element
* @param {string|string[]} [options.cssClasses.body] CSS class to add to the body element
* @param {string|string[]} [options.cssClasses.footer] CSS class to add to the footer element
* @param {boolean|object} [options.pips=true] Show slider pips.
* @param {boolean|object} [options.step=1] Every handle move will jump that number of steps.
* @param {object|boolean} [options.collapsible=false] Hide the widget body and footer when clicking on header
* @param {boolean} [options.collapsible.collapsed] Initial collapsed state of a collapsible widget
* @param {number} [options.min] Minimal slider value, default to automatically computed from the result set
* @param {number} [options.max] Maximal slider value, defaults to automatically computed from the result set
* @return {Object}
*/
const usage = `Usage:
rangeSlider({
container,
attributeName,
[ tooltips=true ],
[ templates.{header, footer} ],
[ cssClasses.{root, header, body, footer} ],
[ step=1 ],
[ pips=true ],
[ autoHideContainer=true ],
[ collapsible=false ],
[ min ],
[ max ]
});
`;
const connectRangeSlider = rangeSliderRendering => ({
container,
attributeName,
tooltips = true,
templates = defaultTemplates,
collapsible = false,
cssClasses: userCssClasses = {},
step = 1,
pips = true,
autoHideContainer = true,
min: userMin,
max: userMax,
precision = 2,
} = {}) => {
if (!container || !attributeName) {
throw new Error(usage);
}

const formatToNumber = v => Number(Number(v).toFixed(precision));

const sliderFormatter = {
from: v => v,
to: v => formatToNumber(v).toLocaleString(),
};

const containerNode = getContainerNode(container);

const cssClasses = {
root: cx(bem(null), userCssClasses.root),
header: cx(bem('header'), userCssClasses.header),
body: cx(bem('body'), userCssClasses.body),
footer: cx(bem('footer'), userCssClasses.footer),
};

return {
getConfiguration: originalConf => {
const conf = {
disjunctiveFacets: [attributeName],
};

if (
(userMin !== undefined || userMax !== undefined)
&&
(!originalConf ||
originalConf.numericRefinements &&
originalConf.numericRefinements[attributeName] === undefined)
) {
conf.numericRefinements = {[attributeName]: {}};

if (userMin !== undefined) {
conf.numericRefinements[attributeName]['>='] = [userMin];
}

if (userMax !== undefined) {
conf.numericRefinements[attributeName]['<='] = [userMax];
}
}

return conf;
},
_getCurrentRefinement(helper) {
let min = helper.state.getNumericRefinement(attributeName, '>=');
let max = helper.state.getNumericRefinement(attributeName, '<=');

if (min && min.length) {
min = min[0];
} else {
min = -Infinity;
}

if (max && max.length) {
max = max[0];
} else {
max = Infinity;
}

return {
min,
max,
};
},
init({helper, templatesConfig}) {
this._templateProps = prepareTemplateProps({
defaultTemplates,
templatesConfig,
templates,
});
this._refine = oldValues => newValues => {
helper.clearRefinements(attributeName);
if (newValues[0] > oldValues.min) {
helper.addNumericRefinement(attributeName, '>=', formatToNumber(newValues[0]));
}
if (newValues[1] < oldValues.max) {
helper.addNumericRefinement(attributeName, '<=', formatToNumber(newValues[1]));
}
helper.search();
};

const stats = {
min: userMin || null,
max: userMax || null,
};
const currentRefinement = this._getCurrentRefinement(helper);

rangeSliderRendering({
collapsible,
cssClasses,
onChange: this._refine(stats),
pips,
range: {min: Math.floor(stats.min), max: Math.ceil(stats.max)},
shouldAutoHideContainer: autoHideContainer && stats.min === stats.max,
start: [currentRefinement.min, currentRefinement.max],
step,
templateProps: this._templateProps,
tooltips,
format: sliderFormatter,
containerNode,
}, true);
},
render({results, helper}) {
const facet = find(results.disjunctiveFacets, {name: attributeName});
const stats = facet !== undefined && facet.stats !== undefined ? facet.stats : {
min: null,
max: null,
};

if (userMin !== undefined) stats.min = userMin;
if (userMax !== undefined) stats.max = userMax;

const currentRefinement = this._getCurrentRefinement(helper);

if (tooltips.format !== undefined) {
tooltips = [{to: tooltips.format}, {to: tooltips.format}];
}

rangeSliderRendering({
collapsible,
cssClasses,
onChange: this._refine(stats),
pips,
range: {min: Math.floor(stats.min), max: Math.ceil(stats.max)},
shouldAutoHideContainer: autoHideContainer && stats.min === stats.max,
start: [currentRefinement.min, currentRefinement.max],
step,
templateProps: this._templateProps,
tooltips,
format: sliderFormatter,
containerNode,
}, false);
},
};
};

export default connectRangeSlider;
31 changes: 7 additions & 24 deletions src/widgets/range-slider/__tests__/range-slider-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,16 +30,9 @@ describe('rangeSlider()', () => {
let results;
let helper;

let autoHideContainer;
let headerFooter;

beforeEach(() => {
ReactDOM = {render: sinon.spy()};
rangeSlider.__Rewire__('ReactDOM', ReactDOM);
autoHideContainer = sinon.stub().returns(Slider);
rangeSlider.__Rewire__('autoHideContainerHOC', autoHideContainer);
headerFooter = sinon.stub().returns(Slider);
rangeSlider.__Rewire__('headerFooterHOC', headerFooter);

container = document.createElement('div');

Expand Down Expand Up @@ -111,8 +104,6 @@ describe('rangeSlider()', () => {
const props = defaultProps;

expect(ReactDOM.render.calledOnce).toBe(true, 'ReactDOM.render called once');
expect(autoHideContainer.calledOnce).toBe(true, 'autoHideContainer called once');
expect(headerFooter.calledOnce).toBe(true, 'headerFooter called once');
expect(ReactDOM.render.firstCall.args[0]).toEqualJSX(<Slider {...props} />);
});

Expand All @@ -137,8 +128,6 @@ describe('rangeSlider()', () => {
};

expect(ReactDOM.render.calledOnce).toBe(true, 'ReactDOM.render called once');
expect(autoHideContainer.calledOnce).toBe(true, 'autoHideContainer called once');
expect(headerFooter.calledOnce).toBe(true, 'headerFooter called once');
expect(ReactDOM.render.firstCall.args[0]).toEqualJSX(<Slider {...props} />);
});
});
Expand Down Expand Up @@ -206,8 +195,6 @@ describe('rangeSlider()', () => {
};

expect(ReactDOM.render.calledOnce).toBe(true, 'ReactDOM.render called once');
expect(autoHideContainer.calledOnce).toBe(true, 'autoHideContainer called once');
expect(headerFooter.calledOnce).toBe(true, 'headerFooter called once');
expect(ReactDOM.render.firstCall.args[0]).toEqualJSX(<Slider {...props} />);
});
});
Expand Down Expand Up @@ -248,8 +235,6 @@ describe('rangeSlider()', () => {
};

expect(ReactDOM.render.calledOnce).toBe(true, 'ReactDOM.render called once');
expect(autoHideContainer.calledOnce).toBe(true, 'autoHideContainer called once');
expect(headerFooter.calledOnce).toBe(true, 'headerFooter called once');
expect(ReactDOM.render.firstCall.args[0]).toEqualJSX(<Slider {...props} />);
});
});
Expand Down Expand Up @@ -348,8 +333,6 @@ describe('rangeSlider()', () => {
};

expect(ReactDOM.render.calledTwice).toBe(true, 'ReactDOM.render called twice');
expect(autoHideContainer.calledOnce).toBe(true, 'autoHideContainer called once');
expect(headerFooter.calledOnce).toBe(true, 'headerFooter called once');
expect(ReactDOM.render.firstCall.args[0]).toEqualJSX(<Slider {...props} />);
expect(ReactDOM.render.firstCall.args[1]).toEqual(container);
expect(ReactDOM.render.secondCall.args[0]).toEqualJSX(<Slider {...props} />);
Expand All @@ -369,10 +352,10 @@ describe('rangeSlider()', () => {
const targetValue = stats.min + 1;

const state0 = helper.state;
widget._refine(helper, stats, [targetValue, stats.max]);
widget._refine(stats)([targetValue, stats.max]);
const state1 = helper.state;

expect(helper.search.calledOnce).toBe(true, 'search called once');
expect(helper.search.callCount).toBe(1);
expect(state1).toEqual(state0.addNumericRefinement('aNumAttr', '>=', targetValue));
});

Expand All @@ -381,10 +364,10 @@ describe('rangeSlider()', () => {
const targetValue = stats.max - 1;

const state0 = helper.state;
widget._refine(helper, stats, [stats.min, targetValue]);
widget._refine(stats)([stats.min, targetValue]);
const state1 = helper.state;

expect(helper.search.calledOnce).toBe(true, 'search called once');
expect(helper.search.callCount).toBe(1);
expect(state1).toEqual(state0.addNumericRefinement('aNumAttr', '<=', targetValue));
});

Expand All @@ -393,12 +376,12 @@ describe('rangeSlider()', () => {
const targetValue = [stats.min + 1, stats.max - 1];

const state0 = helper.state;
widget._refine(helper, stats, targetValue);
widget._refine(stats)(targetValue);
const state1 = helper.state;

const expectedState = state0.
addNumericRefinement('aNumAttr', '>=', targetValue[0]).
addNumericRefinement('aNumAttr', '<=', targetValue[1]);
addNumericRefinement('aNumAttr', '>=', targetValue[0]).
addNumericRefinement('aNumAttr', '<=', targetValue[1]);

expect(state1).toEqual(expectedState);
expect(helper.search.calledOnce).toBe(true, 'search called once');
Expand Down
Loading

0 comments on commit 1a02798

Please sign in to comment.