Merge pull request #698 from sharetribe/mobile-features-filter

Filter search results by listing features
This commit is contained in:
Hannu Lyytikäinen 2018-02-09 10:26:01 +02:00 committed by GitHub
commit 8237cae379
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 229 additions and 216 deletions

View file

@ -12,7 +12,7 @@ import { createResourceLocatorString } from '../../util/routes';
import css from './SearchFilters.css';
const CATEGORY_URL_PARAM = 'pub_category';
const AMENITIES_URL_PARAM = 'pub_amenities';
const FEATURES_URL_PARAM = 'pub_amenities';
// Dropdown container can have a positional offset (in pixels)
const FILTER_DROPDOWN_OFFSET = -14;
@ -49,8 +49,8 @@ const SearchFiltersComponent = props => {
id: 'SearchFilters.featuresLabel',
});
const initialFeatures = !!urlQueryParams[AMENITIES_URL_PARAM]
? urlQueryParams[AMENITIES_URL_PARAM].split(',')
const initialFeatures = !!urlQueryParams[FEATURES_URL_PARAM]
? urlQueryParams[FEATURES_URL_PARAM].split(',')
: [];
const initialCategory = urlQueryParams[CATEGORY_URL_PARAM];
@ -58,8 +58,8 @@ const SearchFiltersComponent = props => {
const handleSelectOptions = (urlParam, options) => {
const queryParams =
options && options.length > 0
? { ...urlQueryParams, [AMENITIES_URL_PARAM]: options.join(',') }
: omit(urlQueryParams, AMENITIES_URL_PARAM);
? { ...urlQueryParams, [FEATURES_URL_PARAM]: options.join(',') }
: omit(urlQueryParams, FEATURES_URL_PARAM);
history.push(createResourceLocatorString('SearchPage', routeConfiguration(), {}, queryParams));
};
@ -88,7 +88,7 @@ const SearchFiltersComponent = props => {
const featuresFilter = features ? (
<SelectMultipleFilter
name="amenities"
urlParam={AMENITIES_URL_PARAM}
urlParam={FEATURES_URL_PARAM}
label={featuresLabel}
onSelect={handleSelectOptions}
options={features}

View file

@ -65,7 +65,6 @@
.modalHeadingWrapper {
padding-bottom: 31px;
border-bottom: 1px solid var(--matterColorNegative);
margin-bottom: 27px;
}
.modalHeading {
@ -95,6 +94,12 @@
}
}
.filtersWrapper {
/* add bottom margin so that the last filter won't be hidden
* under the bottom bar */
margin-bottom: 160px;
}
.showListingsContainer {
position: fixed;
bottom: 0;

View file

@ -7,11 +7,18 @@ import { omit, toPairs } from 'lodash';
import routeConfiguration from '../../routeConfiguration';
import { createResourceLocatorString } from '../../util/routes';
import { SecondaryButton, ModalInMobile, Button, SelectSingleFilterMobile } from '../../components';
import {
SecondaryButton,
ModalInMobile,
Button,
SelectSingleFilterMobile,
SelectMultipleFilterMobile,
} from '../../components';
import css from './SearchFiltersMobile.css';
const CATEGORY_URL_PARAM = 'pub_category';
const allowedCategories = [CATEGORY_URL_PARAM];
const FEATURES_URL_PARAM = 'pub_amenities';
const allowedCategories = [CATEGORY_URL_PARAM, FEATURES_URL_PARAM];
const validateParamValue = value => value !== null && value !== undefined && value.length > 0;
const validateParamKey = key => allowedCategories.includes(key);
@ -33,7 +40,8 @@ class SearchFiltersMobileComponent extends Component {
this.cancelFilters = this.cancelFilters.bind(this);
this.closeFilters = this.closeFilters.bind(this);
this.resetAll = this.resetAll.bind(this);
this.onSelectSingle = this.onSelectSingle.bind(this);
this.handleSelectSingle = this.handleSelectSingle.bind(this);
this.handleSelectMultiple = this.handleSelectMultiple.bind(this);
}
// Open filters modal, set the initial parameters to current ones
@ -65,7 +73,7 @@ class SearchFiltersMobileComponent extends Component {
this.setState({ isFiltersOpenOnMobile: false });
}
onSelectSingle(urlParam, option) {
handleSelectSingle(urlParam, option) {
const { urlQueryParams, history } = this.props;
// query parameters after selecting the option
@ -77,11 +85,22 @@ class SearchFiltersMobileComponent extends Component {
history.push(createResourceLocatorString('SearchPage', routeConfiguration(), {}, queryParams));
}
handleSelectMultiple(urlParam, options) {
const { urlQueryParams, history } = this.props;
const queryParams =
options && options.length > 0
? { ...urlQueryParams, [FEATURES_URL_PARAM]: options.join(',') }
: omit(urlQueryParams, FEATURES_URL_PARAM);
history.push(createResourceLocatorString('SearchPage', routeConfiguration(), {}, queryParams));
}
// Reset all filter query parameters
resetAll(e) {
const { urlQueryParams, history } = this.props;
const queryParams = omit(urlQueryParams, CATEGORY_URL_PARAM);
const queryParams = omit(urlQueryParams, [CATEGORY_URL_PARAM, FEATURES_URL_PARAM]);
history.push(createResourceLocatorString('SearchPage', routeConfiguration(), {}, queryParams));
// blur event target if event is passed
@ -102,6 +121,7 @@ class SearchFiltersMobileComponent extends Component {
onMapIconClick,
onManageDisableScrolling,
categories,
features,
intl,
} = this.props;
@ -139,13 +159,30 @@ class SearchFiltersMobileComponent extends Component {
<SelectSingleFilterMobile
urlParam={CATEGORY_URL_PARAM}
label={categoryLabel}
onSelect={this.onSelectSingle}
onSelect={this.handleSelectSingle}
options={categories}
initialValue={initialCategory}
intl={intl}
/>
) : null;
const featuresLabel = intl.formatMessage({ id: 'SearchFiltersMobile.featuresLabel' });
const initialFeatures = !!urlQueryParams[FEATURES_URL_PARAM]
? urlQueryParams[FEATURES_URL_PARAM].split(',')
: [];
const featuresFilter = features ? (
<SelectMultipleFilterMobile
name="amenities"
urlParam={FEATURES_URL_PARAM}
label={featuresLabel}
onSelect={this.handleSelectMultiple}
options={features}
initialValues={initialFeatures}
/>
) : null;
return (
<div className={classes}>
<div className={css.searchResultSummary}>
@ -174,7 +211,10 @@ class SearchFiltersMobileComponent extends Component {
<FormattedMessage id={'SearchFiltersMobile.resetAll'} />
</button>
</div>
{categoryFilter}
<div className={css.filtersWrapper}>
{categoryFilter}
{featuresFilter}
</div>
<div className={css.showListingsContainer}>
<Button className={css.showListingsButton} onClick={this.closeFilters}>
{showListingsLabel}
@ -193,6 +233,8 @@ SearchFiltersMobileComponent.defaultProps = {
searchingInProgress: false,
onOpenModal: null,
onCloseModal: null,
categories: null,
features: null,
};
SearchFiltersMobileComponent.propTypes = {
@ -208,6 +250,7 @@ SearchFiltersMobileComponent.propTypes = {
onOpenModal: func,
onCloseModal: func,
categories: array,
features: array,
// from injectIntl
intl: intlShape.isRequired,

View file

@ -127,6 +127,7 @@ class SelectMultipleFilter extends Component {
{buttonLabel}
</button>
<SelectMultipleFilterForm
form={`SelectMultipleFilterForm.${name}`}
onSubmit={this.handleSubmit}
initialValues={namedInitialValues}
enableReinitialize={true}

View file

@ -94,8 +94,8 @@ SelectMultipleFilterFormComponent.propTypes = {
intl: intlShape.isRequired,
};
const defaultFormName = 'SelectMultipleFilterForm';
export default compose(reduxForm({ form: defaultFormName }), injectIntl)(
const SelectMultipleFilterForm = compose(reduxForm({}), injectIntl)(
SelectMultipleFilterFormComponent
);
export default SelectMultipleFilterForm;

View file

@ -2,5 +2,60 @@
.root {
padding-bottom: 16px;
margin-top: 30px;
border-bottom: 1px solid var(--matterColorNegative);
}
.filterLabel,
.filterLabelSelected {
@apply --marketplaceH3FontStyles;
margin-bottom: 16px;
}
.filterLabel {
color: var(--matterColorDark);
}
.filterLabelSelected {
color: var(--marketplaceColor);
}
.labelButton {
/* Override button styles */
outline: none;
text-align: left;
border: none;
padding: 0;
}
.optionsContainerOpen {
padding-left: 20px;
height: auto;
}
.optionsContainerClosed {
height: 0;
overflow: hidden;
}
.clearButton {
@apply --marketplaceH5FontStyles;
font-weight: var(--fontWeightMedium);
color: var(--matterColorAnti);
/* Layout */
display: inline;
float: right;
margin-top: 6px;
padding: 0;
/* Override button styles */
outline: none;
text-align: left;
border: none;
&:focus,
&:hover {
color: var(--matterColor);
}
}

View file

@ -1,73 +1,105 @@
import React from 'react';
import { array, string, arrayOf, func, shape } from 'prop-types';
import React, { Component } from 'react';
import { array, string, func } from 'prop-types';
import classNames from 'classnames';
import { injectIntl, intlShape, FormattedMessage } from 'react-intl';
import SelectMultipleFilterMobileForm from './SelectMultipleFilterMobileForm';
import { arrayToFormValues, formValuesToArray } from '../../util/data';
import css from './SelectMultipleFilterMobile.css';
const SelectMultipleFilterMobile = props => {
const {
rootClassName,
className,
name,
urlParam,
label,
onSelect,
options,
initialValues,
} = props;
class SelectMultipleFilterMobileComponent extends Component {
constructor(props) {
super(props);
this.state = { isOpen: true };
const handleSelect = values => {
this.handleSelect = this.handleSelect.bind(this);
this.handleClear = this.handleClear.bind(this);
this.toggleIsOpen = this.toggleIsOpen.bind(this);
}
handleSelect(values) {
const { urlParam, name, onSelect } = this.props;
const selectedKeys = formValuesToArray(values[name]);
onSelect(urlParam, selectedKeys);
};
}
const handleClear = () => {
handleClear() {
const { urlParam, onSelect } = this.props;
onSelect(urlParam, null);
};
}
const classes = classNames(rootClassName || css.root, className);
toggleIsOpen() {
this.setState(prevState => ({ isOpen: !prevState.isOpen }));
}
const initialValuesObj = arrayToFormValues(initialValues);
const namedInitialValues = { [name]: initialValuesObj };
render() {
const { rootClassName, className, name, label, options, initialValues, intl } = this.props;
return (
<div className={classes}>
<SelectMultipleFilterMobileForm
name={name}
label={label}
options={options}
initialValues={namedInitialValues}
initialValuesCount={initialValues.length}
onChange={handleSelect}
onClear={handleClear}
/>
</div>
);
};
const classes = classNames(rootClassName || css.root, className);
SelectMultipleFilterMobile.defaultProps = {
const hasInitialValues = initialValues.length > 0;
const labelClass = hasInitialValues ? css.filterLabelSelected : css.filterLabel;
const labelText = hasInitialValues
? intl.formatMessage(
{ id: 'SelectMultipleFilterMobileForm.labelSelected' },
{ labelText: label, count: initialValues.length }
)
: label;
const optionsContainerClass = this.state.isOpen
? css.optionsContainerOpen
: css.optionsContainerClosed;
const initialValuesObj = arrayToFormValues(initialValues);
const namedInitialValues = { [name]: initialValuesObj };
return (
<div className={classes}>
<div className={labelClass}>
<button type="button" className={css.labelButton} onClick={this.toggleIsOpen}>
<span className={labelClass}>{labelText}</span>
</button>
<button type="button" className={css.clearButton} onClick={this.handleClear}>
<FormattedMessage id={'SelectMultipleFilterMobileForm.clear'} />
</button>
</div>
<SelectMultipleFilterMobileForm
form={`SelectMultipleFilterMobileForm.${name}`}
className={optionsContainerClass}
name={name}
options={options}
initialValues={namedInitialValues}
onChange={this.handleSelect}
enableReinitialize={true}
keepDirtyOnReinitialize={true}
/>
</div>
);
}
}
SelectMultipleFilterMobileComponent.defaultProps = {
rootClassName: null,
className: null,
initialValues: [],
};
SelectMultipleFilterMobile.propTypes = {
SelectMultipleFilterMobileComponent.propTypes = {
rootClassName: string,
className: string,
name: string.isRequired,
urlParam: string.isRequired,
label: string.isRequired,
onSelect: func.isRequired,
options: arrayOf(
shape({
key: string.isRequired,
label: string.isRequired,
})
).isRequired,
options: array.isRequired,
initialValues: array,
// from injectIntl
intl: intlShape.isRequired,
};
const SelectMultipleFilterMobile = injectIntl(SelectMultipleFilterMobileComponent);
export default SelectMultipleFilterMobile;

View file

@ -1,58 +0,0 @@
@import '../../marketplace.css';
.root {
}
.filterLabel,
.filterLabelSelected {
@apply --marketplaceH3FontStyles;
margin-bottom: 16px;
}
.filterLabel {
color: var(--matterColorDark);
}
.filterLabelSelected {
color: var(--marketplaceColor);
}
.labelButton {
/* Override button styles */
outline: none;
text-align: left;
border: none;
padding: 0;
}
.optionsContainerOpen {
padding-left: 20px;
height: auto;
}
.optionsContainerClosed {
height: 0;
overflow: hidden;
}
.clearButton {
@apply --marketplaceH5FontStyles;
font-weight: var(--fontWeightMedium);
color: var(--matterColorAnti);
/* Layout */
display: inline;
float: right;
margin-top: 6px;
padding: 0;
/* Override button styles */
outline: none;
text-align: left;
border: none;
&:focus,
&:hover {
color: var(--matterColor);
}
}

View file

@ -1,92 +1,35 @@
import React, { Component } from 'react';
import { arrayOf, func, node, number, shape, string } from 'prop-types';
import { compose } from 'redux';
import React from 'react';
import { arrayOf, shape, string, node } from 'prop-types';
import { reduxForm, propTypes as formPropTypes } from 'redux-form';
import { FormattedMessage, injectIntl, intlShape } from 'react-intl';
import { FieldGroupCheckbox, Form } from '../../components';
import css from './SelectMultipleFilterMobileForm.css';
class SelectMultipleFilterMobileFormComponent extends Component {
constructor(props) {
super(props);
this.state = { isOpen: true };
const SelectMultipleFilterMobileFormComponent = props => {
const { form, className, name, options } = props;
this.handleClear = this.handleClear.bind(this);
this.toggleIsOpen = this.toggleIsOpen.bind(this);
}
handleClear() {
const { destroy, onClear } = this.props;
// destroy redux form state so that initial values passed
// to the form are also cleared
destroy();
onClear();
}
toggleIsOpen() {
this.setState(prevState => ({ isOpen: !prevState.isOpen }));
}
render() {
const { form, name, label, options, initialValuesCount, intl } = this.props;
const hasInitialValues = initialValuesCount > 0;
const labelClass = hasInitialValues ? css.filterLabelSelected : css.filterLabel;
const labelText = hasInitialValues
? intl.formatMessage(
{ id: 'SelectMultipleFilterMobileForm.labelSelected' },
{ labelText: label, count: initialValuesCount }
)
: label;
const optionsContainerClass = this.state.isOpen
? css.optionsContainerOpen
: css.optionsContainerClosed;
return (
<Form>
<div className={labelClass}>
<button type="button" className={css.labelButton} onClick={this.toggleIsOpen}>
<span className={labelClass}>{labelText}</span>
</button>
<button type="button" className={css.clearButton} onClick={this.handleClear}>
<FormattedMessage id={'SelectMultipleFilterMobileForm.clear'} />
</button>
</div>
<div className={optionsContainerClass}>
<FieldGroupCheckbox name={name} id={`${form}.${name}`} options={options} />
</div>
</Form>
);
}
}
return (
<Form className={className}>
<FieldGroupCheckbox name={name} id={`${form}.${name}`} options={options} />
</Form>
);
};
SelectMultipleFilterMobileFormComponent.defaultProps = {
initialValuesCount: 0,
className: null,
};
SelectMultipleFilterMobileFormComponent.propTypes = {
...formPropTypes,
className: string,
name: string.isRequired,
label: string.isRequired,
onClear: func.isRequired,
options: arrayOf(
shape({
key: string.isRequired,
label: node.isRequired,
})
).isRequired,
initialValuesCount: number,
// form injectIntl
intl: intlShape.isRequired,
};
const defaultFormName = 'SelectMultipleFilterMobileForm';
const SelectMultipleFilterMobileForm = reduxForm({})(SelectMultipleFilterMobileFormComponent);
export default compose(reduxForm({ form: defaultFormName }), injectIntl)(
SelectMultipleFilterMobileFormComponent
);
export default SelectMultipleFilterMobileForm;

View file

@ -2,6 +2,7 @@
.root {
padding-bottom: 16px;
margin-top: 30px;
border-bottom: 1px solid var(--matterColorNegative);
}

View file

@ -331,6 +331,7 @@ export class SearchPageComponent extends Component {
onOpenModal={this.onOpenMobileModal}
onCloseModal={this.onCloseMobileModal}
categories={categories}
features={features}
/>
<div
className={classNames(css.listings, {

View file

@ -42,6 +42,7 @@ describe('SearchPageComponent', () => {
sendVerificationEmailInProgress: false,
onResendVerificationEmail: noop,
categories: [{ key: 'cat1', label: 'Cat 1' }, { key: 'cat2', label: 'Cat 2' }],
features: [{ key: 'dog1', label: 'Dog 1' }, { key: 'dog2', label: 'Dog 2' }],
};
const tree = renderShallow(<SearchPageComponent {...props} />);
expect(tree).toMatchSnapshot();

View file

@ -36,36 +36,12 @@ exports[`SearchPageComponent matches snapshot 1`] = `
features={
Array [
Object {
"key": "towels",
"label": "Towels",
"key": "dog1",
"label": "Dog 1",
},
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",
"key": "dog2",
"label": "Dog 2",
},
]
}
@ -98,6 +74,18 @@ exports[`SearchPageComponent matches snapshot 1`] = `
},
]
}
features={
Array [
Object {
"key": "dog1",
"label": "Dog 1",
},
Object {
"key": "dog2",
"label": "Dog 2",
},
]
}
listingsAreLoaded={true}
onCloseModal={[Function]}
onManageDisableScrolling={[Function]}

View file

@ -494,6 +494,7 @@
"SearchFilters.openMapView": "Map",
"SearchFiltersMobile.cancel": "CANCEL",
"SearchFiltersMobile.categoryLabel": "Category",
"SearchFiltersMobile.featuresLabel": "Amenities",
"SearchFiltersMobile.heading": "Filter saunas",
"SearchFiltersMobile.resetAll": "Reset all",
"SearchFiltersMobile.showListings": "Show {count, number} {count, plural, one {sauna} other {saunas}}",