Merge pull request #691 from sharetribe/mobile-multi-select-filter-component

Select multiple search filter component for mobile
This commit is contained in:
Hannu Lyytikäinen 2018-02-08 13:01:27 +02:00 committed by GitHub
commit cce2f76872
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 398 additions and 38 deletions

View file

@ -11,13 +11,15 @@ import { SecondaryButton, ModalInMobile, Button, SelectSingleFilterMobile } from
import css from './SearchFiltersMobile.css';
const CATEGORY_URL_PARAM = 'pub_category';
const allowedCategories = [CATEGORY_URL_PARAM];
const validateParamValue = value => value !== null && value !== undefined && value.length > 0;
const validateParamKey = key => allowedCategories.includes(key);
// Check if a filter parameter is included query parameters
const hasFilterQueryParams = queryParams => {
const firstFilterParam = toPairs(queryParams).find(entry => {
return validateParamValue(entry[1]);
return validateParamKey(entry[0]) && validateParamValue(entry[1]);
});
return !!firstFilterParam;
};
@ -131,13 +133,15 @@ class SearchFiltersMobileComponent extends Component {
const categoryLabel = intl.formatMessage({
id: 'SearchFiltersMobile.categoryLabel',
});
const initialCategory = urlQueryParams[CATEGORY_URL_PARAM];
const categoryFilter = categories ? (
<SelectSingleFilterMobile
urlQueryParams={urlQueryParams}
urlParam={CATEGORY_URL_PARAM}
paramLabel={categoryLabel}
label={categoryLabel}
onSelect={this.onSelectSingle}
options={categories}
initialValue={initialCategory}
intl={intl}
/>
) : null;

View file

@ -2,34 +2,13 @@ import React, { Component } from 'react';
import { array, arrayOf, func, number, string } from 'prop-types';
import classNames from 'classnames';
import { injectIntl, intlShape } from 'react-intl';
import { toPairs } from 'lodash';
import { arrayToFormValues, formValuesToArray } from '../../util/data';
import SelectMultipleFilterForm from './SelectMultipleFilterForm';
import css from './SelectMultipleFilter.css';
const KEY_CODE_ESCAPE = 27;
/**
* Convert an array of option keys into
* an object that can be passed to a redux
* form as initial values.
*/
const keysToValues = keys => {
return keys.reduce((map, key) => {
map[key] = true;
return map;
}, {});
};
/**
* Convert an object containing values received
* from a redux form into an array of values.
*/
const valuesToKeys = values => {
const entries = toPairs(values);
return entries.filter(entry => entry[1] === true).map(entry => entry[0]);
};
class SelectMultipleFilter extends Component {
constructor(props) {
super(props);
@ -49,7 +28,7 @@ class SelectMultipleFilter extends Component {
handleSubmit(values) {
const { name, onSelect, urlParam } = this.props;
const selectedKeys = valuesToKeys(values[name]);
const selectedKeys = formValuesToArray(values[name]);
this.setState({ isOpen: false });
onSelect(urlParam, selectedKeys);
}
@ -129,7 +108,7 @@ class SelectMultipleFilter extends Component {
// turn a list of values into a map that can be passed to
// a redux form
const initialValuesObj = keysToValues(initialValues);
const initialValuesObj = arrayToFormValues(initialValues);
// pass the initial values with the name key so that
// they can be passed to the correct field

View file

@ -0,0 +1,6 @@
@import '../../marketplace.css';
.root {
padding-bottom: 16px;
border-bottom: 1px solid var(--matterColorNegative);
}

View file

@ -0,0 +1,71 @@
import React from 'react';
import { withRouter } from 'react-router-dom';
import SelectMultipleFilterMobile from './SelectMultipleFilterMobile';
import { stringify, parse } from '../../util/urlHelpers';
const URL_PARAM = 'pub_amenities';
const options = [
{
key: 'towels',
label: 'Towels',
},
{
key: 'bathroom',
label: 'Bathroom',
},
{
key: 'swimming_pool',
label: 'Swimming pool',
},
{
key: 'own_drinks',
label: 'Own drinks allowed',
},
{
key: 'jacuzzi',
label: 'Jacuzzi',
},
{
key: 'audiovisual_entertainment',
label: 'Audiovisual entertainment',
},
{
key: 'barbeque',
label: 'Barbeque',
},
{
key: 'own_food_allowed',
label: 'Own food allowed',
},
];
const handleSelect = (urlParam, values, history) => {
const queryParams = values ? `?${stringify({ [urlParam]: values.join(',') })}` : '';
history.push(`${window.location.pathname}${queryParams}`);
};
const AmenitiesFilterComponent = props => {
const { history, location } = props;
const params = parse(location.search);
const amenities = params[URL_PARAM];
const initialValues = !!amenities ? amenities.split(',') : [];
return (
<SelectMultipleFilterMobile
name="amenities"
urlParam={URL_PARAM}
label="Amenities"
options={options}
onSelect={(urlParam, values) => handleSelect(urlParam, values, history)}
initialValues={initialValues}
/>
);
};
export const AmenitiesFilter = {
component: withRouter(AmenitiesFilterComponent),
props: {},
group: 'misc',
};

View file

@ -0,0 +1,73 @@
import React from 'react';
import { array, string, arrayOf, func, shape } from 'prop-types';
import classNames from 'classnames';
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;
const handleSelect = values => {
const selectedKeys = formValuesToArray(values[name]);
onSelect(urlParam, selectedKeys);
};
const handleClear = () => {
onSelect(urlParam, null);
};
const classes = classNames(rootClassName || css.root, className);
const initialValuesObj = arrayToFormValues(initialValues);
const namedInitialValues = { [name]: initialValuesObj };
return (
<div className={classes}>
<SelectMultipleFilterMobileForm
name={name}
label={label}
options={options}
initialValues={namedInitialValues}
initialValuesCount={initialValues.length}
onChange={handleSelect}
onClear={handleClear}
/>
</div>
);
};
SelectMultipleFilterMobile.defaultProps = {
rootClassName: null,
className: null,
initialValues: [],
};
SelectMultipleFilterMobile.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,
initialValues: array,
};
export default SelectMultipleFilterMobile;

View file

@ -0,0 +1,58 @@
@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

@ -0,0 +1,92 @@
import React, { Component } from 'react';
import { arrayOf, func, node, number, shape, string } from 'prop-types';
import { compose } from 'redux';
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 };
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>
);
}
}
SelectMultipleFilterMobileFormComponent.defaultProps = {
initialValuesCount: 0,
};
SelectMultipleFilterMobileFormComponent.propTypes = {
...formPropTypes,
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';
export default compose(reduxForm({ form: defaultFormName }), injectIntl)(
SelectMultipleFilterMobileFormComponent
);

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 } from 'prop-types';
import classNames from 'classnames';
import { FormattedMessage } from 'react-intl';
@ -28,12 +28,9 @@ class SelectSingleFilterMobile extends Component {
}
render() {
const { rootClassName, className, urlQueryParams, urlParam, paramLabel, options } = this.props;
const { rootClassName, className, label, options, initialValue } = this.props;
// current value of this custom attribute filter
const currentValue = urlQueryParams[urlParam];
const labelClass = currentValue ? css.filterLabelSelected : css.filterLabel;
const labelClass = initialValue ? css.filterLabelSelected : css.filterLabel;
const optionsContainerClass = this.state.isOpen
? css.optionsContainerOpen
@ -45,7 +42,7 @@ class SelectSingleFilterMobile extends Component {
<div className={classes}>
<div className={labelClass}>
<button className={css.labelButton} onClick={this.toggleIsOpen}>
<span className={labelClass}>{paramLabel}</span>
<span className={labelClass}>{label}</span>
</button>
<button className={css.clearButton} onClick={e => this.selectOption(null, e)}>
<FormattedMessage id={'SelectSingleFilterMobile.clear'} />
@ -54,7 +51,7 @@ class SelectSingleFilterMobile extends Component {
<div className={optionsContainerClass}>
{options.map(option => {
// check if this option is selected
const selected = currentValue === option.key;
const selected = initialValue === option.key;
// menu item border class
const optionBorderClass = selected ? css.optionBorderSelected : css.optionBorder;
return (
@ -77,14 +74,14 @@ class SelectSingleFilterMobile extends Component {
SelectSingleFilterMobile.defaultProps = {
rootClassName: null,
className: null,
initialValue: null,
};
SelectSingleFilterMobile.propTypes = {
rootClassName: string,
className: string,
urlQueryParams: object.isRequired,
urlParam: string.isRequired,
paramLabel: string.isRequired,
label: string.isRequired,
onSelect: func.isRequired,
options: arrayOf(
@ -93,6 +90,7 @@ SelectSingleFilterMobile.propTypes = {
label: string.isRequired,
})
).isRequired,
initialValue: string,
};
export default SelectSingleFilterMobile;

View file

@ -108,6 +108,9 @@ export { default as SectionLocations } from './SectionLocations/SectionLocations
export { default as SectionThumbnailLinks } from './SectionThumbnailLinks/SectionThumbnailLinks';
export { default as SelectField } from './SelectField/SelectField';
export { default as SelectMultipleFilter } from './SelectMultipleFilter/SelectMultipleFilter';
export {
default as SelectMultipleFilterMobile,
} from './SelectMultipleFilterMobile/SelectMultipleFilterMobile';
export { default as SelectSingleFilter } from './SelectSingleFilter/SelectSingleFilter';
export {
default as SelectSingleFilterMobile,

View file

@ -46,6 +46,7 @@ import * as Reviews from './components/Reviews/Reviews.example';
import * as SectionThumbnailLinks from './components/SectionThumbnailLinks/SectionThumbnailLinks.example';
import * as SelectField from './components/SelectField/SelectField.example';
import * as SelectMultipleFilter from './components/SelectMultipleFilter/SelectMultipleFilter.example';
import * as SelectMultipleFilterMobile from './components/SelectMultipleFilterMobile/SelectMultipleFilterMobile.example';
import * as StripeBankAccountTokenInputField from './components/StripeBankAccountTokenInputField/StripeBankAccountTokenInputField.example';
import * as TabNav from './components/TabNav/TabNav.example';
import * as TabNavHorizontal from './components/TabNavHorizontal/TabNavHorizontal.example';
@ -138,6 +139,7 @@ export {
SectionThumbnailLinks,
SelectField,
SelectMultipleFilter,
SelectMultipleFilterMobile,
SendMessageForm,
SignupForm,
StripeBankAccountTokenInputField,

View file

@ -523,6 +523,8 @@
"SelectMultipleFilterForm.clear": "Clear",
"SelectMultipleFilterForm.cancel": "Cancel",
"SelectMultipleFilterForm.submit": "Apply",
"SelectMultipleFilterMobileForm.clear": "Clear",
"SelectMultipleFilterMobileForm.labelSelected": "{labelText} • {count}",
"SelectSingleFilter.clear": "Clear",
"SelectSingleFilterMobile.clear": "Clear",
"SendMessageForm.sendFailed": "Failed to send. Please try again.",

View file

@ -1,4 +1,4 @@
import { isArray, reduce } from 'lodash';
import { isArray, reduce, toPairs } from 'lodash';
/**
* Combine the given relationships objects
@ -281,3 +281,39 @@ export const overrideArrays = (objValue, srcValue, key, object, source, stack) =
return srcValue;
}
};
/**
* Converts an array of strings into an object where the array items
* are keys and values in all fields are true. This kind of object
* can be passed as initialValues parameter to a ReduxForm that contains
* checkbox inputs.
*
* @param {Array} array An array of strings
*
* @return {Object} An object containing the array items as keys and all
* the values set to true
*
* A complementary function to formValuesToArray.
*
*/
export const arrayToFormValues = array => {
return array.reduce((map, key) => {
map[key] = true;
return map;
}, {});
};
/**
* Converts a values object received form a Redux Form containing
* checkboxes into an array that contains only the values.
*
* @param {Object} formValues A values object received from a Redux Form
*
* @return {Array} An array containing the keys of the formValues parameter
*
* A complementary function to arrayToFormValues.
*/
export const formValuesToArray = formValues => {
const entries = toPairs(formValues);
return entries.filter(entry => entry[1] === true).map(entry => entry[0]);
};

View file

@ -4,6 +4,8 @@ import {
combinedResourceObjects,
updatedEntities,
denormalisedEntities,
arrayToFormValues,
formValuesToArray,
} from './data';
const { UUID } = sdkTypes;
@ -326,4 +328,38 @@ describe('data utils', () => {
]);
});
});
describe('arrayToFormValues()', () => {
it('converts an empty array', () => {
expect(arrayToFormValues([])).toEqual({});
});
it('converts multiple values', () => {
const array = ['one', 'two', 'three'];
const formValues = { one: true, two: true, three: true };
expect(arrayToFormValues(array)).toEqual(formValues);
});
});
describe('formValuesToArray()', () => {
it('converts an empty object', () => {
expect(formValuesToArray({})).toEqual([]);
});
it('converts multiple values', () => {
const formValues = { one: true, two: true, three: true };
const array = ['one', 'two', 'three'];
expect(formValuesToArray(formValues)).toEqual(array);
});
it('it skips non-true values form input object', () => {
const formValues = {
one: true,
two: null,
three: 'true',
four: false,
five: '',
six: true,
};
const array = ['one', 'six'];
expect(formValuesToArray(formValues)).toEqual(array);
});
});
});