diff --git a/src/components/SearchFiltersMobile/SearchFiltersMobile.js b/src/components/SearchFiltersMobile/SearchFiltersMobile.js index df4c1571..7e2d3cca 100644 --- a/src/components/SearchFiltersMobile/SearchFiltersMobile.js +++ b/src/components/SearchFiltersMobile/SearchFiltersMobile.js @@ -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 ? ( ) : null; diff --git a/src/components/SelectMultipleFilter/SelectMultipleFilter.js b/src/components/SelectMultipleFilter/SelectMultipleFilter.js index d9f3d823..37206a3e 100644 --- a/src/components/SelectMultipleFilter/SelectMultipleFilter.js +++ b/src/components/SelectMultipleFilter/SelectMultipleFilter.js @@ -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 diff --git a/src/components/SelectMultipleFilterMobile/SelectMultipleFilterMobile.css b/src/components/SelectMultipleFilterMobile/SelectMultipleFilterMobile.css new file mode 100644 index 00000000..4621d7d7 --- /dev/null +++ b/src/components/SelectMultipleFilterMobile/SelectMultipleFilterMobile.css @@ -0,0 +1,6 @@ +@import '../../marketplace.css'; + +.root { + padding-bottom: 16px; + border-bottom: 1px solid var(--matterColorNegative); +} diff --git a/src/components/SelectMultipleFilterMobile/SelectMultipleFilterMobile.example.js b/src/components/SelectMultipleFilterMobile/SelectMultipleFilterMobile.example.js new file mode 100644 index 00000000..6b63bcb5 --- /dev/null +++ b/src/components/SelectMultipleFilterMobile/SelectMultipleFilterMobile.example.js @@ -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 ( + handleSelect(urlParam, values, history)} + initialValues={initialValues} + /> + ); +}; + +export const AmenitiesFilter = { + component: withRouter(AmenitiesFilterComponent), + props: {}, + group: 'misc', +}; diff --git a/src/components/SelectMultipleFilterMobile/SelectMultipleFilterMobile.js b/src/components/SelectMultipleFilterMobile/SelectMultipleFilterMobile.js new file mode 100644 index 00000000..79d9826d --- /dev/null +++ b/src/components/SelectMultipleFilterMobile/SelectMultipleFilterMobile.js @@ -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 ( + + + + ); +}; + +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; diff --git a/src/components/SelectMultipleFilterMobile/SelectMultipleFilterMobileForm.css b/src/components/SelectMultipleFilterMobile/SelectMultipleFilterMobileForm.css new file mode 100644 index 00000000..65477bf5 --- /dev/null +++ b/src/components/SelectMultipleFilterMobile/SelectMultipleFilterMobileForm.css @@ -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); + } +} diff --git a/src/components/SelectMultipleFilterMobile/SelectMultipleFilterMobileForm.js b/src/components/SelectMultipleFilterMobile/SelectMultipleFilterMobileForm.js new file mode 100644 index 00000000..fd36e9e4 --- /dev/null +++ b/src/components/SelectMultipleFilterMobile/SelectMultipleFilterMobileForm.js @@ -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 ( + + + + {labelText} + + + + + + + + + + + ); + } +} + +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 +); diff --git a/src/components/SelectSingleFilterMobile/SelectSingleFilterMobile.js b/src/components/SelectSingleFilterMobile/SelectSingleFilterMobile.js index 966e94a1..faa9cd02 100644 --- a/src/components/SelectSingleFilterMobile/SelectSingleFilterMobile.js +++ b/src/components/SelectSingleFilterMobile/SelectSingleFilterMobile.js @@ -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 { - {paramLabel} + {label} this.selectOption(null, e)}> @@ -54,7 +51,7 @@ class SelectSingleFilterMobile extends Component { {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; diff --git a/src/components/index.js b/src/components/index.js index b581ac2b..e695b313 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -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, diff --git a/src/examples.js b/src/examples.js index b91b8070..91364bb7 100644 --- a/src/examples.js +++ b/src/examples.js @@ -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, diff --git a/src/translations/en.json b/src/translations/en.json index 2fa55de3..b7ee01b0 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -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.", diff --git a/src/util/data.js b/src/util/data.js index 1e8e3e47..f7268e4f 100644 --- a/src/util/data.js +++ b/src/util/data.js @@ -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]); +}; diff --git a/src/util/data.test.js b/src/util/data.test.js index 013cb0bc..aa9ed9df 100644 --- a/src/util/data.test.js +++ b/src/util/data.test.js @@ -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); + }); + }); });