appbaseio/reactivesearch

View on GitHub
packages/maps/src/components/basic/GeoDistanceSlider.js

Summary

Maintainability
F
1 wk
Test Coverage
import React, { Component } from 'react';
import Downshift from 'downshift';
import { withTheme } from 'emotion-theming';

import {
    addComponent,
    removeComponent,
    watchComponent,
    updateQuery,
    setQueryListener,
} from '@appbaseio/reactivecore/lib/actions';
import {
    isEqual,
    checkValueChange,
    checkSomePropChange,
    checkPropChange,
    getClassName,
} from '@appbaseio/reactivecore/lib/utils/helper';
import Rheostat from 'rheostat/lib/Slider';

import types from '@appbaseio/reactivecore/lib/utils/types';

import Title from '@appbaseio/reactivesearch/lib/styles/Title';
import Input, {
    suggestionsContainer,
    suggestions,
} from '@appbaseio/reactivesearch/lib/styles/Input';
import InputIcon from '@appbaseio/reactivesearch/lib/styles/InputIcon';
import SearchSvg from '@appbaseio/reactivesearch/lib/components/shared/SearchSvg';
import Slider from '@appbaseio/reactivesearch/lib/styles/Slider';
import RangeLabel from '@appbaseio/reactivesearch/lib/components/range/addons/RangeLabel';
import SliderHandle from '@appbaseio/reactivesearch/lib/components/range/addons/SliderHandle';
import { rangeLabelsContainer } from '@appbaseio/reactivesearch/lib/styles/Label';
import { connect } from '@appbaseio/reactivesearch/lib/utils';

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

        this.state = {
            currentLocation: null,
            currentDistance: props.range.start,
            userLocation: null,
            suggestions: [],
            isOpen: false,
        };
        this.type = 'geo_distance';
        this.locked = false;
        this.coordinates = null;
        this.autocompleteService = null;

        if (props.autoLocation) {
            this.getUserLocation();
        }
        props.setQueryListener(props.componentId, props.onQueryChange, null);
    }

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

        if (this.props.selectedValue && this.props.selectedValue.location) {
            this.setState(
                {
                    currentLocation: this.props.selectedValue.location,
                },
                () => {
                    this.getCoordinates(this.props.selectedValue.location, () => {
                        if (this.props.selectedValue.distance) {
                            this.setDistance(this.props.selectedValue.distance);
                        }
                    });
                },
            );
        } else if (this.props.defaultSelected && this.props.defaultSelected.location) {
            this.setState(
                {
                    currentLocation: this.props.defaultSelected.location,
                },
                () => {
                    this.getCoordinates(this.props.defaultSelected.location, () => {
                        if (this.props.defaultSelected.distance) {
                            this.setDistance(this.props.defaultSelected.distance);
                        }
                    });
                },
            );
        }
    }

    componentDidMount() {
        this.autocompleteService = new window.google.maps.places.AutocompleteService();
    }

    componentWillReceiveProps(nextProps) {
        checkPropChange(this.props.react, nextProps.react, () => this.setReact(nextProps));

        checkSomePropChange(this.props, nextProps, ['dataField', 'nestedField'], () => {
            this.updateQuery(this.state.currentDistance, nextProps);
        });

        if (
            nextProps.defaultSelected
            && nextProps.defaultSelected.distance
            && nextProps.defaultSelected.location
            && !isEqual(this.props.defaultSelected, nextProps.defaultSelected)
        ) {
            this.setValues(nextProps.defaultSelected);
        } else if (
            nextProps.selectedValue
            && nextProps.selectedValue.distance
            && nextProps.selectedValue.location
            && !isEqual(this.state.currentLocation, nextProps.selectedValue.location)
        ) {
            this.setValues(nextProps.selectedValue);
        } else if (
            !isEqual(this.props.selectedValue, nextProps.selectedValue)
            && !nextProps.selectedValue
        ) {
            this.setState(
                {
                    currentLocation: null,
                    currentDistance: null,
                },
                () => {
                    this.updateQuery(null);
                },
            );
        }
    }

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

    setReact(props) {
        if (props.react) {
            props.watchComponent(props.componentId, props.react);
        }
    }

    setValues = (selected) => {
        this.setState({
            currentLocation: selected.location,
        });
        this.getCoordinates(selected.location, () => {
            this.setDistance(selected.distance);
        });
    };

    defaultQuery = (coordinates, distance, props) => {
        let query = null;
        if (coordinates && distance) {
            query = {
                [this.type]: {
                    distance: `${distance}${props.unit}`,
                    [props.dataField]: coordinates,
                },
            };
        }

        if (query && props.nestedField) {
            return {
                query: {
                    nested: {
                        path: props.nestedField,
                        query,
                    },
                },
            };
        }

        return query;
    };

    getUserLocation() {
        navigator.geolocation.getCurrentPosition((location) => {
            const coordinates = `${location.coords.latitude}, ${location.coords.longitude}`;

            fetch(
                `https://maps.googleapis.com/maps/api/geocode/json?key=${
                    this.props.mapKey
                }&v=3.31&latlng=${coordinates}`,
            )
                .then(res => res.json())
                .then((res) => {
                    if (Array.isArray(res.results) && res.results.length) {
                        const userLocation = res.results[0].formatted_address;
                        this.setState({
                            userLocation,
                        });
                    }
                })
                .catch((e) => {
                    console.error(e);
                });
        });
    }

    getCoordinates(value, cb) {
        if (value) {
            fetch(
                `https://maps.googleapis.com/maps/api/geocode/json?key=${
                    this.props.mapKey
                }&v=3.31&address=${value}`,
            )
                .then(res => res.json())
                .then((res) => {
                    if (Array.isArray(res.results) && res.results.length) {
                        const { location } = res.results[0].geometry;
                        this.coordinates = `${location.lat}, ${location.lng}`;
                    }
                })
                .then(() => {
                    if (cb) cb();
                })
                .catch((e) => {
                    console.error(e);
                });
        }
    }

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

        this.locked = true;

        const performUpdate = () => {
            this.setState(
                {
                    currentLocation: currentValue.value,
                    isOpen: false,
                },
                () => {
                    this.getCoordinates(currentValue.value, () => {
                        if (this.state.currentDistance) {
                            this.updateQuery(this.state.currentDistance);
                            if (props.onValueChange) {
                                props.onValueChange({
                                    distance: this.state.currentDistance,
                                    location: currentValue.value,
                                });
                            }
                        }
                        this.locked = false;
                    });
                },
            );
        };

        checkValueChange(
            props.componentId,
            { distance: this.state.currentDistance, location: currentValue.value },
            props.beforeValueChange,
            performUpdate,
        );
    };

    setDistance = (currentDistance) => {
        this.setState(
            {
                currentDistance,
            },
            () => {
                if (this.state.currentLocation) {
                    this.updateQuery(currentDistance, this.props);
                }
            },
        );
    };

    updateQuery = (distance, props = this.props) => {
        const query = props.customQuery || this.defaultQuery;

        let value = null;
        if (distance && this.state.currentLocation) {
            value = {
                distance,
                location: this.state.currentLocation,
                // unit: props.unit,
            };
        }

        props.updateQuery({
            componentId: props.componentId,
            query: query(this.coordinates, distance, props),
            value,
            label: props.filterLabel,
            showFilter: props.showFilter,
            URLParams: props.URLParams,
        });
    };

    renderIcon = () => {
        if (this.props.showIcon) {
            return this.props.icon || <SearchSvg />;
        }
        return null;
    };

    onInputChange = (e) => {
        const { value } = e.target;
        this.setState({
            currentLocation: value,
        });
        if (value.trim()) {
            if (!this.autocompleteService) {
                this.autocompleteService = new window.google.maps.places.AutocompleteService();
            }

            const restrictedCountries = this.props.countries || [];

            this.autocompleteService.getPlacePredictions(
                {
                    input: value,
                    componentRestrictions: { country: restrictedCountries },
                    ...this.props.serviceOptions,
                },
                (res) => {
                    const suggestionsList
                        = (res
                            && res.map(place => ({
                                label: place.description,
                                value: place.description,
                            })))
                        || [];

                    this.setState({
                        suggestions: suggestionsList,
                    });
                },
            );
        } else {
            this.setState({
                suggestions: [],
            });
        }
    };

    handleFocus = (event) => {
        this.setState({
            isOpen: true,
        });
        if (this.props.onFocus) {
            this.props.onFocus(event);
        }
    };

    handleOuterClick = () => {
        this.setLocation({ value: this.state.currentLocation });
    };

    handleStateChange = (changes) => {
        const { isOpen, type } = changes;
        if (type === Downshift.stateChangeTypes.mouseUp) {
            this.setState({
                isOpen,
            });
        }
    };

    renderSearchBox = () => {
        let suggestionsList = [...this.state.suggestions];
        const { theme, themePreset } = this.props;

        if (this.state.userLocation) {
            suggestionsList = [
                {
                    label: 'Use my current location',
                    value: this.state.userLocation,
                },
                ...this.state.suggestions,
            ];
        }

        return (
            <Downshift
                onChange={this.setLocation}
                onOuterClick={this.handleOuterClick}
                onStateChange={this.handleStateChange}
                isOpen={this.state.isOpen}
                itemToString={i => i}
                render={({
                    getInputProps, getItemProps, isOpen, highlightedIndex,
                }) => (
                    <div className={suggestionsContainer}>
                        <Input
                            showIcon={this.props.showIcon}
                            iconPosition={this.props.iconPosition}
                            innerRef={this.props.innerRef}
                            {...getInputProps({
                                className: getClassName(this.props.innerClass, 'input'),
                                placeholder: this.props.placeholder,
                                value: this.state.currentLocation || '',
                                onChange: this.onInputChange,
                                onBlur: this.props.onBlur,
                                onFocus: this.handleFocus,
                                onKeyPress: this.props.onKeyPress,
                                onKeyDown: this.handleKeyDown,
                                onKeyUp: this.props.onKeyUp,
                            })}
                            themePreset={themePreset}
                        />
                        <InputIcon iconPosition={this.props.iconPosition}>
                            {this.renderIcon()}
                        </InputIcon>
                        {isOpen && this.state.suggestions.length ? (
                            <ul
                                className={`${suggestions(themePreset, theme)} ${getClassName(
                                    this.props.innerClass,
                                    'list',
                                )}`}
                            >
                                {suggestionsList.slice(0, 11).map((item, index) => (
                                    <li
                                        {...getItemProps({ item })}
                                        key={item.label}
                                        style={{
                                            backgroundColor:
                                                highlightedIndex === index ? '#eee' : '#fff',
                                        }}
                                    >
                                        {typeof item.label === 'string' ? (
                                            <div
                                                className="trim"
                                                dangerouslySetInnerHTML={{
                                                    __html: item.label,
                                                }}
                                            />
                                        ) : (
                                            item.label
                                        )}
                                    </li>
                                ))}
                            </ul>
                        ) : null}
                    </div>
                )}
            />
        );
    };

    handleSlider = ({ values }) => {
        if (values[0] !== this.state.currentDistance) {
            this.setDistance(values[0]);
        }
    };

    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.renderSearchBox()}
                <Rheostat
                    min={this.props.range.start}
                    max={this.props.range.end}
                    values={[this.state.currentDistance]}
                    onChange={this.handleSlider}
                    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 ? (
                    <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>
                ) : null}
            </Slider>
        );
    }
}

GeoDistanceSlider.propTypes = {
    addComponent: types.funcRequired,
    mapKey: types.stringRequired,
    removeComponent: types.funcRequired,
    selectedValue: types.selectedValue,
    setQueryListener: types.funcRequired,
    themePreset: types.themePreset,
    updateQuery: types.funcRequired,
    watchComponent: types.funcRequired,
    // component props
    autoLocation: types.bool,
    beforeValueChange: types.func,
    className: types.string,
    componentId: types.stringRequired,
    countries: types.stringArray,
    customQuery: types.func,
    data: types.data,
    dataField: types.stringRequired,
    defaultSelected: types.selectedValue,
    filterLabel: types.string,
    icon: types.children,
    iconPosition: types.iconPosition,
    innerClass: types.style,
    innerRef: types.func,
    nestedField: types.string,
    onBlur: types.func,
    onFocus: types.func,
    onKeyDown: types.func,
    onKeyPress: types.func,
    onKeyUp: types.func,
    onQueryChange: types.func,
    onValueChange: types.func,
    placeholder: types.string,
    range: types.range,
    rangeLabels: types.rangeLabels,
    react: types.react,
    serviceOptions: types.props,
    showFilter: types.bool,
    showIcon: types.bool,
    tooltipTrigger: types.tooltipTrigger,
    renderTooltipData: types.func,
    style: types.style,
    theme: types.style,
    title: types.title,
    unit: types.string,
    URLParams: types.bool,
};

GeoDistanceSlider.defaultProps = {
    className: null,
    placeholder: 'Select a value',
    range: {
        start: 1,
        end: 200,
    },
    showFilter: true,
    tooltipTrigger: 'none',
    style: {},
    URLParams: false,
    autoLocation: true,
    unit: 'mi',
    countries: [],
};

const mapStateToProps = (state, props) => ({
    mapKey: state.config.mapKey,
    selectedValue:
        (state.selectedValues[props.componentId]
            && state.selectedValues[props.componentId].value)
        || null,
    themePreset: state.config.themePreset,
});

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

export default connect(
    mapStateToProps,
    mapDispatchtoProps,
)(withTheme(GeoDistanceSlider));