appbaseio/reactivesearch

View on GitHub
packages/web/src/components/range/RangeSlider.js

Summary

Maintainability
F
1 wk
Test Coverage
import React, { Component } from 'react';
import {
    addComponent,
    removeComponent,
    watchComponent,
    updateQuery,
    setQueryOptions,
    setQueryListener,
} from '@appbaseio/reactivecore/lib/actions';
import {
    isEqual,
    checkValueChange,
    checkPropChange,
    checkSomePropChange,
    getClassName,
    pushToAndClause,
} from '@appbaseio/reactivecore/lib/utils/helper';
import types from '@appbaseio/reactivecore/lib/utils/types';
import Rheostat from 'rheostat/lib/Slider';

import HistogramContainer from './addons/HistogramContainer';
import RangeLabel from './addons/RangeLabel';
import SliderHandle from './addons/SliderHandle';
import Slider from '../../styles/Slider';
import Title from '../../styles/Title';
import { rangeLabelsContainer } from '../../styles/Label';
import { connect } from '../../utils';

class RangeSlider extends Component {
    constructor(props) {
        super(props);

        this.state = {
            currentValue: [props.range.start, props.range.end],
            stats: [],
        };

        this.locked = false;
        this.internalComponent = `${this.props.componentId}__internal`;
        props.setQueryListener(props.componentId, props.onQueryChange, null);
    }

    componentWillMount() {
        this.props.addComponent(this.props.componentId);
        this.props.addComponent(this.internalComponent);

        this.updateQueryOptions(this.props);
        this.setReact(this.props);

        const { selectedValue, defaultSelected } = this.props;
        if (Array.isArray(selectedValue)) {
            this.handleChange(selectedValue);
        } else if (selectedValue) {
            // for value as an object for SSR
            this.handleChange(RangeSlider.parseValue(selectedValue, this.props));
        } else if (defaultSelected) {
            this.handleChange(RangeSlider.parseValue(defaultSelected, this.props));
        }
    }

    componentWillReceiveProps(nextProps) {
        checkPropChange(this.props.react, nextProps.react, () => this.setReact(nextProps));
        checkSomePropChange(this.props, nextProps, ['showHistogram', 'interval'], () =>
            this.updateQueryOptions(nextProps),
        );
        checkPropChange(this.props.options, nextProps.options, () => {
            const { options } = nextProps;
            if (Array.isArray(options)) {
                options.sort((a, b) => {
                    if (a.key < b.key) return -1;
                    if (a.key > b.key) return 1;
                    return 0;
                });
            }
            this.setState({
                stats: options || [],
            });
        });

        checkSomePropChange(this.props, nextProps, ['dataField', 'nestedField'], () => {
            this.updateQueryOptions(nextProps);
            this.handleChange(this.state.currentValue, nextProps);
        });

        if (!isEqual(this.props.defaultSelected, nextProps.defaultSelected)) {
            this.handleChange(
                [nextProps.defaultSelected.start, nextProps.defaultSelected.end],
                nextProps,
            );
        } else if (!isEqual(this.state.currentValue, nextProps.selectedValue)) {
            this.handleChange(
                nextProps.selectedValue || [nextProps.range.start, nextProps.range.end],
            );
        }
    }

    shouldComponentUpdate(nextProps) {
        const upperLimit = Math.floor((nextProps.range.end - nextProps.range.start) / 2);
        if (nextProps.stepValue < 1 || nextProps.stepValue > upperLimit) {
            console.warn(
                `stepValue for RangeSlider ${
                    nextProps.componentId
                } should be greater than 0 and less than or equal to ${upperLimit}`,
            );
            return false;
        }
        return true;
    }

    componentWillUnmount() {
        this.props.removeComponent(this.props.componentId);
        this.props.removeComponent(this.internalComponent);
    }

    setReact = (props) => {
        const { react } = props;
        if (react) {
            const newReact = pushToAndClause(react, this.internalComponent);
            props.watchComponent(props.componentId, newReact);
        } else {
            props.watchComponent(props.componentId, {
                and: this.internalComponent,
            });
        }
    };

    static parseValue = (value, props) =>
        (value ? [value.start, value.end] : [props.range.start, props.range.end]);

    static defaultQuery = (value, props) => {
        let query = null;
        if (Array.isArray(value) && value.length) {
            query = {
                range: {
                    [props.dataField]: {
                        gte: value[0],
                        lte: value[1],
                        boost: 2.0,
                    },
                },
            };
        }
        if (query && props.nestedField) {
            return {
                query: {
                    nested: {
                        path: props.nestedField,
                        query,
                    },
                },
            };
        }

        return query;
    };

    getSnapPoints = () => {
        let snapPoints = [];
        let { stepValue } = this.props;

        // limit the number of steps to prevent generating a large number of snapPoints
        if ((this.props.range.end - this.props.range.start) / stepValue > 100) {
            stepValue = (this.props.range.end - this.props.range.start) / 100;
        }

        for (let i = this.props.range.start; i <= this.props.range.end; i += stepValue) {
            snapPoints = snapPoints.concat(i);
        }
        if (snapPoints[snapPoints.length - 1] !== this.props.range.end) {
            snapPoints = snapPoints.concat(this.props.range.end);
        }
        return snapPoints;
    };

    getValidInterval = (props) => {
        const min = Math.ceil((props.range.end - props.range.start) / 100) || 1;
        if (!props.interval) {
            return min;
        } else if (props.interval < min) {
            console.error(
                `${
                    props.componentId
                }: interval prop's value should be greater than or equal to ${min}`,
            );
            return min;
        }
        return props.interval;
    };

    histogramQuery = props => ({
        [props.dataField]: {
            histogram: {
                field: props.dataField,
                interval: this.getValidInterval(props),
                offset: props.range.start,
            },
        },
    });

    handleChange = (currentValue, props = this.props) => {
        // ignore state updates when component is locked
        if (props.beforeValueChange && this.locked) {
            return;
        }

        this.locked = true;
        const performUpdate = () => {
            this.setState(
                {
                    currentValue,
                },
                () => {
                    this.updateQuery([currentValue[0], currentValue[1]], props);
                    this.locked = false;
                    if (props.onValueChange) {
                        props.onValueChange({
                            start: currentValue[0],
                            end: currentValue[1],
                        });
                    }
                },
            );
        };
        checkValueChange(
            props.componentId,
            {
                start: currentValue[0],
                end: currentValue[1],
            },
            props.beforeValueChange,
            performUpdate,
        );
    };

    handleSlider = ({ values }) => {
        if (!isEqual(values, this.state.currentValue)) {
            this.handleChange(values);
        }
    };

    handleDrag = (values) => {
        if (this.props.onDrag) {
            const { min, max, values: currentValue } = values;
            this.props.onDrag(currentValue, [min, max]);
        }
    };

    updateQuery = (value, props) => {
        const query = props.customQuery || RangeSlider.defaultQuery;
        const {
            showFilter,
            range: { start, end },
        } = props;
        const [currentStart, currentEnd] = value;
        // check if the slider is at its initial position
        const isInitialValue = currentStart === start && currentEnd === end;
        props.updateQuery({
            componentId: props.componentId,
            query: query(value, props),
            value,
            label: props.filterLabel,
            showFilter: showFilter && !isInitialValue,
            URLParams: props.URLParams,
            componentType: 'RANGESLIDER',
        });
    };

    updateQueryOptions = (props) => {
        if (props.showHistogram) {
            const queryOptions = {
                size: 0,
                aggs: (props.histogramQuery || this.histogramQuery)(props),
            };

            props.setQueryOptions(this.internalComponent, queryOptions, false);

            const query = props.customQuery || RangeSlider.defaultQuery;

            props.updateQuery({
                componentId: this.internalComponent,
                query: query([props.range.start, props.range.end], props),
            });
        }
    };

    render() {
        return (
            <Slider primary style={this.props.style} className={this.props.className}>
                {this.props.title && (
                    <Title className={getClassName(this.props.innerClass, 'title') || null}>
                        {this.props.title}
                    </Title>
                )}
                {this.state.stats.length && this.props.showHistogram && this.props.showSlider ? (
                    <HistogramContainer
                        stats={this.state.stats}
                        range={this.props.range}
                        interval={this.getValidInterval(this.props)}
                    />
                ) : null}
                {this.props.showSlider && (
                    <Rheostat
                        min={this.props.range.start}
                        max={this.props.range.end}
                        values={this.state.currentValue}
                        onChange={this.handleSlider}
                        onValuesUpdated={this.handleDrag}
                        snap={this.props.snap}
                        snapPoints={this.props.snap ? this.getSnapPoints() : null}
                        className={getClassName(this.props.innerClass, 'slider')}
                        handle={({ className, style, ...passProps }) => (
                            <SliderHandle
                                style={style}
                                className={className}
                                {...passProps}
                                renderTooltipData={this.props.renderTooltipData}
                                tooltipTrigger={this.props.tooltipTrigger}
                            />
                        )}
                    />
                )}
                {this.props.rangeLabels && this.props.showSlider && (
                    <div className={rangeLabelsContainer}>
                        <RangeLabel
                            align="left"
                            className={getClassName(this.props.innerClass, 'label') || null}
                        >
                            {this.props.rangeLabels.start}
                        </RangeLabel>
                        <RangeLabel
                            align="right"
                            className={getClassName(this.props.innerClass, 'label') || null}
                        >
                            {this.props.rangeLabels.end}
                        </RangeLabel>
                    </div>
                )}
            </Slider>
        );
    }
}

RangeSlider.propTypes = {
    addComponent: types.funcRequired,
    removeComponent: types.funcRequired,
    setQueryListener: types.funcRequired,
    setQueryOptions: types.funcRequired,
    updateQuery: types.funcRequired,
    watchComponent: types.funcRequired,
    options: types.options,
    selectedValue: types.selectedValue,
    // component props
    beforeValueChange: types.func,
    className: types.string,
    componentId: types.stringRequired,
    customQuery: types.func,
    dataField: types.stringRequired,
    defaultSelected: types.range,
    filterLabel: types.string,
    innerClass: types.style,
    interval: types.number,
    onDrag: types.func,
    onQueryChange: types.func,
    onValueChange: types.func,
    range: types.range,
    rangeLabels: types.rangeLabels,
    react: types.react,
    showHistogram: types.bool,
    histogramQuery: types.func,
    showFilter: types.bool,
    showSlider: types.bool,
    tooltipTrigger: types.tooltipTrigger,
    renderTooltipData: types.func,
    snap: types.bool,
    stepValue: types.number,
    style: types.style,
    title: types.title,
    nestedField: types.string,
    URLParams: types.bool,
};

RangeSlider.defaultProps = {
    className: null,
    range: {
        start: 0,
        end: 10,
    },
    showHistogram: true,
    showSlider: true,
    tooltipTrigger: 'none',
    snap: true,
    stepValue: 1,
    showFilter: true,
    style: {},
    URLParams: false,
};

const mapStateToProps = (state, props) => ({
    options: state.aggregations[props.componentId]
        ? state.aggregations[props.componentId][props.dataField]
          && state.aggregations[props.componentId][props.dataField].buckets // eslint-disable-line
        : [],
    selectedValue: state.selectedValues[props.componentId]
        ? state.selectedValues[props.componentId].value
        : null,
});

const mapDispatchtoProps = dispatch => ({
    addComponent: component => dispatch(addComponent(component)),
    removeComponent: component => dispatch(removeComponent(component)),
    setQueryOptions: (component, props, execute) =>
        dispatch(setQueryOptions(component, props, execute)),
    setQueryListener: (component, onQueryChange, beforeQueryChange) =>
        dispatch(setQueryListener(component, onQueryChange, beforeQueryChange)),
    updateQuery: updateQueryObject => dispatch(updateQuery(updateQueryObject)),
    watchComponent: (component, react) => dispatch(watchComponent(component, react)),
});

export default connect(
    mapStateToProps,
    mapDispatchtoProps,
)(RangeSlider);