Merge pull request #689 from sharetribe/select-multiple-filter-search

Filter search with amenities
This commit is contained in:
Hannu Lyytikäinen 2018-02-06 13:47:04 +02:00 committed by GitHub
commit 9ae4db24a9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 150 additions and 42 deletions

View file

@ -6,12 +6,16 @@ import classNames from 'classnames';
import { withRouter } from 'react-router-dom';
import { omit } from 'lodash';
import { SelectSingleFilter } from '../../components';
import { SelectSingleFilter, SelectMultipleFilter } from '../../components';
import routeConfiguration from '../../routeConfiguration';
import { createResourceLocatorString } from '../../util/routes';
import css from './SearchFilters.css';
const CATEGORY_URL_PARAM = 'pub_category';
const AMENITIES_URL_PARAM = 'pub_amenities';
// Dropdown container can have a positional offset (in pixels)
const FILTER_DROPDOWN_OFFSET = -14;
const SearchFiltersComponent = props => {
const {
@ -22,6 +26,7 @@ const SearchFiltersComponent = props => {
resultsCount,
searchInProgress,
categories,
features,
history,
intl,
} = props;
@ -40,7 +45,26 @@ const SearchFiltersComponent = props => {
id: 'SearchFilters.categoryLabel',
});
const onSelectOption = (urlParam, option) => {
const featuresLabel = intl.formatMessage({
id: 'SearchFilters.featuresLabel',
});
const initialFeatures = !!urlQueryParams[AMENITIES_URL_PARAM]
? urlQueryParams[AMENITIES_URL_PARAM].split(',')
: [];
const initialCategory = urlQueryParams[CATEGORY_URL_PARAM];
const handleSelectOptions = (urlParam, options) => {
const queryParams =
options && options.length > 0
? { ...urlQueryParams, [AMENITIES_URL_PARAM]: options.join(',') }
: omit(urlQueryParams, AMENITIES_URL_PARAM);
history.push(createResourceLocatorString('SearchPage', routeConfiguration(), {}, queryParams));
};
const handleSelectOption = (urlParam, option) => {
// query parameters after selecting the option
// if no option is passed, clear the selection for the filter
const queryParams = option
@ -52,16 +76,32 @@ const SearchFiltersComponent = props => {
const categoryFilter = categories ? (
<SelectSingleFilter
urlQueryParams={urlQueryParams}
urlParam={CATEGORY_URL_PARAM}
paramLabel={categoryLabel}
onSelect={onSelectOption}
label={categoryLabel}
onSelect={handleSelectOption}
options={categories}
initialValue={initialCategory}
contentPlacementOffset={FILTER_DROPDOWN_OFFSET}
/>
) : null;
const featuresFilter = features ? (
<SelectMultipleFilter
urlParam={AMENITIES_URL_PARAM}
label={featuresLabel}
onSelect={handleSelectOptions}
options={features}
initialValues={initialFeatures}
contentPlacementOffset={FILTER_DROPDOWN_OFFSET}
/>
) : null;
return (
<div className={classes}>
<div className={css.filters}>{categoryFilter}</div>
<div className={css.filters}>
{categoryFilter}
{featuresFilter}
</div>
<div className={css.searchResultSummary}>
{listingsAreLoaded && resultsCount > 0 ? resultsFound : null}
@ -78,6 +118,7 @@ SearchFiltersComponent.defaultProps = {
resultsCount: null,
searchingInProgress: false,
categories: null,
features: null,
};
SearchFiltersComponent.propTypes = {
@ -90,6 +131,7 @@ SearchFiltersComponent.propTypes = {
onMapIconClick: func.isRequired,
onManageDisableScrolling: func.isRequired,
categories: array,
features: array,
// from withRouter
history: shape({

View file

@ -3,6 +3,8 @@ import { withRouter } from 'react-router-dom';
import SelectMultipleFilter from './SelectMultipleFilter';
import { stringify, parse } from '../../util/urlHelpers';
const URL_PARAM = 'pub_amenities';
const options = [
{
key: 'towels',
@ -38,8 +40,8 @@ const options = [
},
];
const handleSubmit = (values, history) => {
const queryParams = values ? `?${stringify({ pub_amenities: values.join(',') })}` : '';
const handleSubmit = (urlParam, values, history) => {
const queryParams = values ? `?${stringify({ [urlParam]: values.join(',') })}` : '';
history.push(`${window.location.pathname}${queryParams}`);
};
@ -47,12 +49,14 @@ const AmenitiesFilterComponent = withRouter(props => {
const { history, location } = props;
const params = parse(location.search);
const amenities = params.pub_amenities;
const amenities = params[URL_PARAM];
const initialValues = !!amenities ? amenities.split(',') : [];
return (
<SelectMultipleFilter
onSubmit={values => handleSubmit(values, history)}
urlParam={URL_PARAM}
label="Amenities"
onSelect={(urlParam, values) => handleSubmit(urlParam, values, history)}
options={options}
initialValues={initialValues}
contentPlacementOffset={-14}

View file

@ -48,20 +48,21 @@ class SelectMultipleFilter extends Component {
}
handleSubmit(values) {
const { onSelect, urlParam } = this.props;
const selectedKeys = valuesToKeys(values);
this.setState({ isOpen: false });
this.props.onSubmit(selectedKeys);
onSelect(urlParam, selectedKeys);
}
handleClear() {
this.setState({ isOpen: false });
this.props.onSubmit(null);
this.props.onSelect(null);
}
handleCancel() {
const { onSubmit, initialValues } = this.props;
const { onSelect, initialValues, urlParam } = this.props;
this.setState({ isOpen: false });
onSubmit(initialValues);
onSelect(urlParam, initialValues);
}
handleBlur(event) {
@ -111,17 +112,18 @@ class SelectMultipleFilter extends Component {
}
render() {
const { rootClassName, className, options, initialValues, intl } = this.props;
const { rootClassName, className, label, options, initialValues, intl } = this.props;
const classes = classNames(rootClassName || css.root, className);
const hasInitialValues = initialValues.length > 0;
const labelStyles = hasInitialValues ? css.labelSelected : css.label;
const label = hasInitialValues
const buttonLabel = hasInitialValues
? intl.formatMessage(
{ id: 'SelectMultipleFilterMobile.labelSelected' },
{ count: initialValues.length }
{ id: 'SelectMultipleFilter.labelSelected' },
{ labelText: label, count: initialValues.length }
)
: intl.formatMessage({ id: 'SelectMultipleFilterMobile.label' });
: label;
const contentStyle = this.positionStyleForContent();
@ -135,7 +137,7 @@ class SelectMultipleFilter extends Component {
}}
>
<button className={labelStyles} onClick={() => this.toggleOpen()}>
{label}
{buttonLabel}
</button>
<SelectMultipleFilterForm
onSubmit={this.handleSubmit}
@ -166,7 +168,9 @@ SelectMultipleFilter.defaultProps = {
SelectMultipleFilter.propTypes = {
rootClassName: string,
className: string,
onSubmit: func.isRequired,
urlParam: string.isRequired,
label: string.isRequired,
onSelect: func.isRequired,
options: array.isRequired,
initialValues: arrayOf(string),
contentPlacementOffset: number,

View file

@ -36,9 +36,9 @@ const SelectMultipleFilterFormComponent = props => {
onCancel();
};
const clear = intl.formatMessage({ id: 'SelectMultipleFilterMobile.clear' });
const cancel = intl.formatMessage({ id: 'SelectMultipleFilterMobile.cancel' });
const submit = intl.formatMessage({ id: 'SelectMultipleFilterMobile.submit' });
const clear = intl.formatMessage({ id: 'SelectMultipleFilterForm.clear' });
const cancel = intl.formatMessage({ id: 'SelectMultipleFilterForm.cancel' });
const submit = intl.formatMessage({ id: 'SelectMultipleFilterForm.submit' });
return (
<Form

View file

@ -1,5 +1,5 @@
import React, { Component } from 'react';
import { object, string, func, arrayOf, shape } from 'prop-types';
import { string, func, arrayOf, shape, number } from 'prop-types';
import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
@ -30,14 +30,19 @@ class SelectSingleFilter extends Component {
}
render() {
const { rootClassName, className, urlQueryParams, urlParam, paramLabel, options } = this.props;
// current value of this custom attribute filter
const currentValue = urlQueryParams[urlParam];
const {
rootClassName,
className,
urlParam,
label,
options,
initialValue,
contentPlacementOffset,
} = this.props;
// resolve menu label text and class
const menuLabel = currentValue ? optionLabel(options, currentValue) : paramLabel;
const menuLabelClass = currentValue ? css.menuLabelSelected : css.menuLabel;
const menuLabel = initialValue ? optionLabel(options, initialValue) : label;
const menuLabelClass = initialValue ? css.menuLabelSelected : css.menuLabel;
const classes = classNames(rootClassName || css.root, className);
@ -45,7 +50,7 @@ class SelectSingleFilter extends Component {
<Menu
className={classes}
useArrow={false}
contentPlacementOffset={-14}
contentPlacementOffset={contentPlacementOffset}
onToggleActive={this.onToggleActive}
isOpen={this.state.isOpen}
>
@ -53,7 +58,7 @@ class SelectSingleFilter extends Component {
<MenuContent className={css.menuContent}>
{options.map(option => {
// check if this option is selected
const selected = currentValue === option.key;
const selected = initialValue === option.key;
// menu item border class
const menuItemBorderClass = selected ? css.menuItemBorderSelected : css.menuItemBorder;
@ -83,22 +88,24 @@ class SelectSingleFilter extends Component {
SelectSingleFilter.defaultProps = {
rootClassName: null,
className: null,
initialValue: null,
contentPlacementOffset: 0,
};
SelectSingleFilter.propTypes = {
rootClassName: string,
className: string,
urlQueryParams: object.isRequired,
urlParam: string.isRequired,
paramLabel: string.isRequired,
label: string.isRequired,
onSelect: func.isRequired,
options: arrayOf(
shape({
key: string.isRequired,
label: string.isRequired,
})
).isRequired,
initialValue: string,
contentPlacementOffset: number,
};
export default SelectSingleFilter;

View file

@ -43,11 +43,18 @@ const SEARCH_WITH_MAP_DEBOUNCE = 300; // Little bit of debounce before search is
const BOUNDS_FIXED_PRECISION = 8;
const CATEGORY_URL_PARAM = 'pub_category';
const AMENITIES_URL_PARAM = 'pub_amenities';
// extract search parameters, including a custom attribute named category
const pickSearchParamsOnly = params => {
const { address, origin, bounds, ...rest } = params || {};
return { address, origin, bounds, [CATEGORY_URL_PARAM]: rest[CATEGORY_URL_PARAM] };
return {
address,
origin,
bounds,
[CATEGORY_URL_PARAM]: rest[CATEGORY_URL_PARAM],
[AMENITIES_URL_PARAM]: rest[AMENITIES_URL_PARAM],
};
};
export class SearchPageComponent extends Component {
@ -103,6 +110,7 @@ export class SearchPageComponent extends Component {
latlngBounds: ['bounds'],
});
const category = rest[CATEGORY_URL_PARAM];
const features = rest[AMENITIES_URL_PARAM];
const viewportGMapBounds = googleMap.getBounds();
const viewportBounds = sdkBoundsToFixedCoordinates(
@ -124,6 +132,7 @@ export class SearchPageComponent extends Component {
country,
mapSearch: true,
[CATEGORY_URL_PARAM]: category,
[AMENITIES_URL_PARAM]: features,
};
history.push(
createResourceLocatorString('SearchPage', routeConfiguration(), {}, searchParams)
@ -189,6 +198,7 @@ export class SearchPageComponent extends Component {
searchListingsError,
searchParams,
categories,
features,
} = this.props;
// eslint-disable-next-line no-unused-vars
const { mapSearch, page, ...searchInURL } = parse(location.search, {
@ -306,6 +316,7 @@ export class SearchPageComponent extends Component {
onMapIconClick={onMapIconClick}
onManageDisableScrolling={onManageDisableScrolling}
categories={categories}
features={features}
/>
<SearchFiltersMobile
className={css.searchFiltersMobile}
@ -361,6 +372,7 @@ SearchPageComponent.defaultProps = {
searchParams: {},
tab: 'listings',
categories: config.custom.categories,
features: config.custom.amenities,
};
const { array, bool, func, oneOf, object, shape, string } = PropTypes;
@ -377,6 +389,7 @@ SearchPageComponent.propTypes = {
searchParams: object,
tab: oneOf(['filters', 'listings', 'map']).isRequired,
categories: array,
features: array,
// from withRouter
history: shape({

View file

@ -33,6 +33,42 @@ exports[`SearchPageComponent matches snapshot 1`] = `
},
]
}
features={
Array [
Object {
"key": "towels",
"label": "Towels",
},
Object {
"key": "bathroom",
"label": "Bathroom",
},
Object {
"key": "swimming_pool",
"label": "Swimming pool",
},
Object {
"key": "own_drinks",
"label": "Own drinks allowed",
},
Object {
"key": "jacuzzi",
"label": "Jacuzzi",
},
Object {
"key": "audiovisual_entertainment",
"label": "Audiovisual entertainment",
},
Object {
"key": "barbeque",
"label": "Barbeque",
},
Object {
"key": "own_food_allowed",
"label": "Own food allowed",
},
]
}
listingsAreLoaded={true}
onManageDisableScrolling={[Function]}
onMapIconClick={[Function]}
@ -44,6 +80,7 @@ exports[`SearchPageComponent matches snapshot 1`] = `
"address": undefined,
"bounds": undefined,
"origin": undefined,
"pub_amenities": undefined,
"pub_category": undefined,
}
}
@ -75,6 +112,7 @@ exports[`SearchPageComponent matches snapshot 1`] = `
"address": undefined,
"bounds": undefined,
"origin": undefined,
"pub_amenities": undefined,
"pub_category": undefined,
}
}

View file

@ -484,6 +484,7 @@
"ReviewModal.later": "Later",
"ReviewModal.title": "Leave a review for {revieweeName}",
"SearchFilters.categoryLabel": "Category",
"SearchFilters.featuresLabel": "Amenities",
"SearchFilters.filtersButtonLabel": "Filters",
"SearchFilters.foundResults": "{count, number} {count, plural, one {result} other {results}}",
"SearchFilters.loadingResults": "Loading search results…",
@ -518,11 +519,10 @@
"SectionLocations.listingsInLocation": "Saunas in {location}",
"SectionLocations.subtitle": "We have more than 1000 saunas around Finland. Here are some of our most popular locations.",
"SectionLocations.title": "We have wooden saunas, electric saunas, and even tent saunas.",
"SelectMultipleFilterMobile.label": "Amenities",
"SelectMultipleFilterMobile.labelSelected": "Amenities • {count}",
"SelectMultipleFilterMobile.clear": "Clear",
"SelectMultipleFilterMobile.cancel": "Cancel",
"SelectMultipleFilterMobile.submit": "Apply",
"SelectMultipleFilter.labelSelected": "{labelText} • {count}",
"SelectMultipleFilterForm.clear": "Clear",
"SelectMultipleFilterForm.cancel": "Cancel",
"SelectMultipleFilterForm.submit": "Apply",
"SelectSingleFilter.clear": "Clear",
"SelectSingleFilterMobile.clear": "Clear",
"SendMessageForm.sendFailed": "Failed to send. Please try again.",