mirror of
https://github.com/kingomarnajjar/flex-template-web.git
synced 2026-07-26 06:47:17 +10:00
Merge pull request #691 from sharetribe/mobile-multi-select-filter-component
Select multiple search filter component for mobile
This commit is contained in:
commit
cce2f76872
13 changed files with 398 additions and 38 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
@import '../../marketplace.css';
|
||||
|
||||
.root {
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid var(--matterColorNegative);
|
||||
}
|
||||
|
|
@ -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',
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
);
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue