mirror of
https://github.com/kingomarnajjar/flex-template-web.git
synced 2026-07-28 20:53:24 +10:00
Merge pull request #689 from sharetribe/select-multiple-filter-search
Filter search with amenities
This commit is contained in:
commit
9ae4db24a9
8 changed files with 150 additions and 42 deletions
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue