Update filters configuration and validation

Changes the way filters are defined and how the filter params are
validated.
This commit is contained in:
Hannu Lyytikainen 2018-04-18 11:38:46 +03:00
parent 649e073773
commit ae2db17152
8 changed files with 355 additions and 176 deletions

View file

@ -1,6 +1,6 @@
import React from 'react';
import { compose } from 'redux';
import { object, string, bool, number, func, shape, array } from 'prop-types';
import { object, string, bool, number, func, shape } from 'prop-types';
import { injectIntl, intlShape, FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import { withRouter } from 'react-router-dom';
@ -11,12 +11,19 @@ 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;
// resolve initial value for a single value filter
const initialSingleValue = (queryParams, paramName) => {
return queryParams[paramName];
};
// resolve initial values for a multi value filter
const initialMultiValue = (queryParams, paramName) => {
return !!queryParams[paramName] ? queryParams[paramName].split(',') : [];
};
const SearchFiltersComponent = props => {
const {
rootClassName,
@ -25,8 +32,8 @@ const SearchFiltersComponent = props => {
listingsAreLoaded,
resultsCount,
searchInProgress,
categories,
amenities,
categoryFilter,
amenitiesFilter,
isSearchFiltersPanelOpen,
toggleSearchFiltersPanel,
searchFiltersPanelSelectedCount,
@ -45,11 +52,9 @@ const SearchFiltersComponent = props => {
id: 'SearchFilters.amenitiesLabel',
});
const initialAmenities = !!urlQueryParams[AMENITIES_URL_PARAM]
? urlQueryParams[AMENITIES_URL_PARAM].split(',')
: [];
const initialAmenities = initialMultiValue(urlQueryParams, amenitiesFilter.paramName);
const initialCategory = urlQueryParams[CATEGORY_URL_PARAM];
const initialCategory = initialSingleValue(urlQueryParams, categoryFilter.paramName);
const handleSelectOptions = (urlParam, options) => {
const queryParams =
@ -70,24 +75,24 @@ const SearchFiltersComponent = props => {
history.push(createResourceLocatorString('SearchPage', routeConfiguration(), {}, queryParams));
};
const categoryFilter = categories ? (
const categoryFilterElement = categoryFilter ? (
<SelectSingleFilter
urlParam={CATEGORY_URL_PARAM}
urlParam={categoryFilter.paramName}
label={categoryLabel}
onSelect={handleSelectOption}
options={categories}
options={categoryFilter.options}
initialValue={initialCategory}
contentPlacementOffset={FILTER_DROPDOWN_OFFSET}
/>
) : null;
const amenitiesFilter = amenities ? (
const amenitiesFilterElement = amenitiesFilter ? (
<SelectMultipleFilter
name="amenities"
urlParam={AMENITIES_URL_PARAM}
urlParam={amenitiesFilter.paramName}
label={amenitiesLabel}
onSelect={handleSelectOptions}
options={amenities}
options={amenitiesFilter.options}
initialValues={initialAmenities}
contentPlacementOffset={FILTER_DROPDOWN_OFFSET}
/>
@ -113,8 +118,8 @@ const SearchFiltersComponent = props => {
return (
<div className={classes}>
<div className={css.filters}>
{categoryFilter}
{amenitiesFilter}
{categoryFilterElement}
{amenitiesFilterElement}
{toggleSearchFiltersPanelButton}
</div>
@ -146,8 +151,8 @@ SearchFiltersComponent.defaultProps = {
className: null,
resultsCount: null,
searchingInProgress: false,
categories: null,
amenities: null,
categoryFilter: null,
amenitiesFilter: null,
isSearchFiltersPanelOpen: false,
toggleSearchFiltersPanel: null,
searchFiltersPanelSelectedCount: 0,
@ -161,8 +166,8 @@ SearchFiltersComponent.propTypes = {
resultsCount: number,
searchingInProgress: bool,
onManageDisableScrolling: func.isRequired,
categories: array,
amenities: array,
categoriesFilter: object,
amenitiesFilter: object,
isSearchFiltersPanelOpen: bool,
toggleSearchFiltersPanel: func,
searchFiltersPanelSelectedCount: number,

View file

@ -3,7 +3,7 @@ import { object, string, bool, number, func, shape, array } from 'prop-types';
import classNames from 'classnames';
import { FormattedMessage, injectIntl, intlShape } from 'react-intl';
import { withRouter } from 'react-router-dom';
import { omit, toPairs } from 'lodash';
import { omit } from 'lodash';
import routeConfiguration from '../../routeConfiguration';
import { createResourceLocatorString } from '../../util/routes';
@ -16,21 +16,6 @@ import {
} from '../../components';
import css from './SearchFiltersMobile.css';
const CATEGORY_URL_PARAM = 'pub_category';
const AMENITIES_URL_PARAM = 'pub_amenities';
const allowedParams = [CATEGORY_URL_PARAM, AMENITIES_URL_PARAM];
const validateParamValue = value => value !== null && value !== undefined && value.length > 0;
const validateParamKey = key => allowedParams.includes(key);
// Check if a filter parameter is included query parameters
const hasFilterQueryParams = queryParams => {
const firstFilterParam = toPairs(queryParams).find(entry => {
return validateParamKey(entry[0]) && validateParamValue(entry[1]);
});
return !!firstFilterParam;
};
class SearchFiltersMobileComponent extends Component {
constructor(props) {
super(props);
@ -100,9 +85,9 @@ class SearchFiltersMobileComponent extends Component {
// Reset all filter query parameters
resetAll(e) {
const { urlQueryParams, history } = this.props;
const { urlQueryParams, history, filterParamNames } = this.props;
const queryParams = omit(urlQueryParams, [CATEGORY_URL_PARAM, AMENITIES_URL_PARAM]);
const queryParams = omit(urlQueryParams, filterParamNames);
history.push(createResourceLocatorString('SearchPage', routeConfiguration(), {}, queryParams));
// blur event target if event is passed
@ -113,30 +98,28 @@ class SearchFiltersMobileComponent extends Component {
// resolve initial value for a single value filter
initialSingleValue(paramName) {
return this.props.urlQueryParams[paramName]
return this.props.urlQueryParams[paramName];
}
// resolve initial values for a multi value filter
initialMultiValue(paramName) {
const urlQueryParams = this.props.urlQueryParams;
return !!urlQueryParams[paramName]
? urlQueryParams[paramName].split(',')
: [];
return !!urlQueryParams[paramName] ? urlQueryParams[paramName].split(',') : [];
}
render() {
const {
rootClassName,
className,
urlQueryParams,
listingsAreLoaded,
resultsCount,
searchInProgress,
showAsModalMaxWidth,
onMapIconClick,
onManageDisableScrolling,
categories,
amenities,
selectedFiltersCount,
categoryFilter,
amenitiesFilter,
intl,
} = this.props;
@ -155,27 +138,28 @@ class SearchFiltersMobileComponent extends Component {
{ count: resultsCount }
);
const filtersButton = hasFilterQueryParams(urlQueryParams) ? (
<Button className={css.filtersButton} onClick={this.openFilters}>
<FormattedMessage id="SearchFilters.filtersButtonLabel" className={css.mapIconText} />
</Button>
) : (
<SecondaryButton className={css.filtersButton} onClick={this.openFilters}>
<FormattedMessage id="SearchFilters.filtersButtonLabel" className={css.mapIconText} />
</SecondaryButton>
);
const filtersButton =
selectedFiltersCount > 0 ? (
<Button className={css.filtersButton} onClick={this.openFilters}>
<FormattedMessage id="SearchFilters.filtersButtonLabel" className={css.mapIconText} />
</Button>
) : (
<SecondaryButton className={css.filtersButton} onClick={this.openFilters}>
<FormattedMessage id="SearchFilters.filtersButtonLabel" className={css.mapIconText} />
</SecondaryButton>
);
const categoryLabel = intl.formatMessage({
id: 'SearchFiltersMobile.categoryLabel',
});
const initialCategory = this.initialSingleValue(CATEGORY_URL_PARAM);
const initialCategory = this.initialSingleValue(categoryFilter.paramName);
const categoryFilter = categories ? (
const categoryFilterElement = categoryFilter ? (
<SelectSingleFilterPlain
urlParam={CATEGORY_URL_PARAM}
urlParam={categoryFilter.paramName}
label={categoryLabel}
onSelect={this.handleSelectSingle}
options={categories}
options={categoryFilter.options}
initialValue={initialCategory}
intl={intl}
/>
@ -183,15 +167,15 @@ class SearchFiltersMobileComponent extends Component {
const amenitiesLabel = intl.formatMessage({ id: 'SearchFiltersMobile.amenitiesLabel' });
const initialAmenities = this.initialMultiValue(AMENITIES_URL_PARAM);
const initialAmenities = this.initialMultiValue(amenitiesFilter.paramName);
const amenitiesFilter = amenities ? (
const amenitiesFilterElement = amenitiesFilter ? (
<SelectMultipleFilterPlain
name="amenities"
urlParam={AMENITIES_URL_PARAM}
urlParam={amenitiesFilter.paramName}
label={amenitiesLabel}
onSelect={this.handleSelectMultiple}
options={amenities}
options={amenitiesFilter.options}
initialValues={initialAmenities}
/>
) : null;
@ -225,8 +209,8 @@ class SearchFiltersMobileComponent extends Component {
</button>
</div>
<div className={css.filtersWrapper}>
{categoryFilter}
{amenitiesFilter}
{categoryFilterElement}
{amenitiesFilterElement}
</div>
<div className={css.showListingsContainer}>
<Button className={css.showListingsButton} onClick={this.closeFilters}>
@ -244,8 +228,10 @@ SearchFiltersMobileComponent.defaultProps = {
className: null,
resultsCount: null,
searchingInProgress: false,
categories: null,
amenities: null,
selectedFiltersCount: 0,
filterParamNames: [],
categoryFilter: null,
amenitiesFilter: null,
};
SearchFiltersMobileComponent.propTypes = {
@ -260,8 +246,10 @@ SearchFiltersMobileComponent.propTypes = {
onManageDisableScrolling: func.isRequired,
onOpenModal: func.isRequired,
onCloseModal: func.isRequired,
categories: array,
amenities: array,
selectedFiltersCount: number,
filterParamNames: array,
categoriesFilter: object,
amenitiesFilter: object,
// from injectIntl
intl: intlShape.isRequired,

View file

@ -4,33 +4,30 @@
* SearchFilters component (which is the default place for SearchFilters).
*
*
* An example how to render MultiSelectFilter inside render function:
* An example how to render MultiSelectFilter for a filter that has it's config passed in
* the props as newFilter:
*
* const currentQueryParams = this.state.currentQueryParams;
* const splitQueryParam = queryParam => queryParam ? queryParam.split(',') : [];
* initialValue for a filter can be resolved with the initialSingleValue and initialMultiValue
* methods.
*
* // initialValue for a select should come either from state.currentQueryParam or urlQueryParam
* const hascurrentQueryParam = typeof currentQueryParams[MULTI_SELECT_URL_PARAM] !== 'undefined'
* const initialMultiSelectValue = hascurrentQueryParam
* ? splitQueryParam(currentQueryParams[MULTI_SELECT_URL_PARAM])
* : splitQueryParam(this.props.urlQueryParams[MULTI_SELECT_URL_PARAM]);
* const initialNewFilterValues = this.initialMultiValue(newFilter.paramName);
*
* const multiSelectFilterX = multiSelectFilterXFromProps ? (
* const newFilterElement = newFilter ? (
* <SelectMultipleFilterPlain
* id="SearchFiltersPanel.multiSelectFilterX"
* name="multiSelectFilterX"
* urlParam={MULTI_SELECT_URL_PARAM}
* label={this.props.intl.formatMessage({ id: 'SearchFiltersPanel.multiSelectFilterXLabel' })}
* id="SearchFiltersPanel.newFilter"
* name="newFilter"
* urlParam={newFilter.paramName}
* label={this.props.intl.formatMessage({ id: 'SearchFiltersPanel.newFilterLabel' })}
* onSelect={this.handleSelectMultiple}
* options={multiSelectFilterXFromProps}
* initialValues={initialMultiSelectValue}
* initialValues={initialNewFilterValues}
* twoColumns
* />
* ) : null;
*/
import React, { Component } from 'react';
import { func, object, shape, string } from 'prop-types';
import { array, func, object, shape, string } from 'prop-types';
import classNames from 'classnames';
import { FormattedMessage, injectIntl, intlShape } from 'react-intl';
import { withRouter } from 'react-router-dom';
@ -80,9 +77,9 @@ class SearchFiltersPanelComponent extends Component {
// Reset all filter query parameters
resetAll(e) {
const { urlQueryParams, customURLParamToConfig, history, onClosePanel } = this.props;
const { urlQueryParams, history, onClosePanel, filterParamNames } = this.props;
const queryParams = omit(urlQueryParams, Object.keys(customURLParamToConfig));
const queryParams = omit(urlQueryParams, filterParamNames);
history.push(createResourceLocatorString('SearchPage', routeConfiguration(), {}, queryParams));
// Ensure that panel closes (if now changes have been made)
@ -132,13 +129,10 @@ class SearchFiltersPanelComponent extends Component {
const currentQueryParams = this.state.currentQueryParams;
const urlQueryParams = this.props.urlQueryParams;
// initialValue for a select should come either from state.currentQueryParam or urlQueryParam
const currentQueryParam = currentQueryParams[paramName];
return typeof currentQueryParam !== 'undefined'
? currentQueryParam
: urlQueryParams[paramName];
return typeof currentQueryParam !== 'undefined' ? currentQueryParam : urlQueryParams[paramName];
}
// resolve initial values for a multi value filter
@ -146,10 +140,10 @@ class SearchFiltersPanelComponent extends Component {
const currentQueryParams = this.state.currentQueryParams;
const urlQueryParams = this.props.urlQueryParams;
const splitQueryParam = queryParam => queryParam ? queryParam.split(',') : [];
const splitQueryParam = queryParam => (queryParam ? queryParam.split(',') : []);
// initialValue for a select should come either from state.currentQueryParam or urlQueryParam
const hasCurrentQueryParam = typeof currentQueryParams[paramName] !== 'undefined'
const hasCurrentQueryParam = typeof currentQueryParams[paramName] !== 'undefined';
return hasCurrentQueryParam
? splitQueryParam(currentQueryParams[paramName])
@ -182,14 +176,15 @@ class SearchFiltersPanelComponent extends Component {
SearchFiltersPanelComponent.defaultProps = {
rootClassName: null,
className: null,
filterParamNames: [],
};
SearchFiltersPanelComponent.propTypes = {
rootClassName: string,
className: string,
urlQueryParams: object.isRequired,
customURLParamToConfig: object.isRequired,
onClosePanel: func.isRequired,
filterParamNames: array,
// from injectIntl
intl: intlShape.isRequired,

View file

@ -1,7 +1,8 @@
import React, { Component } from 'react';
import { array, bool, func, object, number, string } from 'prop-types';
import { array, bool, func, number, object, objectOf, shape, string } from 'prop-types';
import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import { merge } from 'lodash';
import { propTypes } from '../../util/types';
import {
SearchResultsPanel,
@ -9,6 +10,7 @@ import {
SearchFiltersMobile,
SearchFiltersPanel,
} from '../../components';
import { validFilterParams } from './SearchPage.helpers';
import css from './SearchPage.css';
@ -35,22 +37,20 @@ class MainPanel extends Component {
pagination,
searchParamsForPagination,
showAsModalMaxWidth,
customURLParamToConfig,
primaryFilters,
secondaryFilters,
} = this.props;
const isSearchFiltersPanelOpen = !!secondaryFilters && this.state.isSearchFiltersPanelOpen;
const secondaryFilterKeys = secondaryFilters ? Object.keys(secondaryFilters) : [];
// resolve list of secondary filter url params
const secondaryFilterParams = Object.entries(customURLParamToConfig)
.filter(param => secondaryFilterKeys.includes(param[1]))
.map(e => e[0]);
const filters = merge(primaryFilters, secondaryFilters);
const selectedFilters = validFilterParams(urlQueryParams, filters);
const selectedFiltersCount = Object.keys(selectedFilters).length;
const searchFiltersPanelSelectedCount = !secondaryFilters
? 0
: secondaryFilterParams.map(key => urlQueryParams[key]).filter(param => !!param).length;
const selectedSecondaryFilters = secondaryFilters
? validFilterParams(urlQueryParams, secondaryFilters)
: {};
const searchFiltersPanelSelectedCount = Object.keys(selectedSecondaryFilters).length;
const searchFiltersPanelProps = !!secondaryFilters
? {
@ -68,6 +68,11 @@ class MainPanel extends Component {
const classes = classNames(rootClassName || css.searchResultContainer, className);
const filterParamNames = Object.values(filters).map(f => f.paramName);
const secondaryFilterParamNames = secondaryFilters
? Object.values(secondaryFilters).map(f => f.paramName)
: [];
return (
<div className={classes}>
<SearchFilters
@ -93,6 +98,8 @@ class MainPanel extends Component {
onManageDisableScrolling={onManageDisableScrolling}
onOpenModal={onOpenModal}
onCloseModal={onCloseModal}
filterParamNames={filterParamNames}
selectedFiltersCount={selectedFiltersCount}
{...primaryFilters}
{...secondaryFilters}
/>
@ -100,9 +107,9 @@ class MainPanel extends Component {
<div className={classNames(css.searchFiltersPanel)}>
<SearchFiltersPanel
urlQueryParams={urlQueryParams}
customURLParamToConfig={customURLParamToConfig}
listingsAreLoaded={listingsAreLoaded}
onClosePanel={() => this.setState({ isSearchFiltersPanelOpen: false })}
filterParamNames={secondaryFilterParamNames}
{...secondaryFilters}
/>
</div>
@ -159,9 +166,18 @@ MainPanel.propTypes = {
pagination: propTypes.pagination,
searchParamsForPagination: object,
showAsModalMaxWidth: number.isRequired,
customURLParamToConfig: object.isRequired,
primaryFilters: object,
secondaryFilters: object,
primaryFilters: objectOf(
shape({
paramName: string.isRequired,
options: array.isRequired,
})
),
secondaryFilters: objectOf(
shape({
paramName: string.isRequired,
options: array.isRequired,
})
),
};
export default MainPanel;

View file

@ -4,53 +4,93 @@ import { createResourceLocatorString } from '../../util/routes';
import { createSlug } from '../../util/urlHelpers';
import routeConfiguration from '../../routeConfiguration';
// customURLParams
export const validURLParamForExtendedData = (paramKey, urlParams, customConfigKeys) => {
const configKey = customConfigKeys[paramKey];
const value = urlParams[paramKey];
const valueArray = value ? value.split(',') : [];
/**
* Validates a filter search param agains a filters configuration.
*
* All invalid param names and values are dropped
*
* @param {String} paramName Search parameter name
* @param {Object} paramValue Search parameter value
* @param {Object} filters Filters configuration
*/
export const validURLParamForExtendedData = (paramName, paramValue, filters) => {
const filtersArray = Object.values(filters);
// resolve configuration for this filter
const filterConfig = filtersArray.find(f => f.paramName === paramName);
const valueArray = paramValue ? paramValue.split(',') : [];
if (filterConfig && valueArray.length > 0) {
const allowedValues = filterConfig.options.map(o => o.key);
if (configKey && valueArray.length > 0) {
const allowedValues = config.custom[configKey].map(a => a.key);
const validValues = intersection(valueArray, allowedValues).join(',');
return validValues.length > 0 ? { [paramKey]: validValues } : {};
return validValues.length > 0 ? { [paramName]: validValues } : {};
}
return {};
};
// validate filter params
export const validURLParamsForExtendedData = (params, customURLParamToConfig) => {
const paramKeys = Object.keys(params);
const customURLParams = Object.keys(customURLParamToConfig);
return paramKeys.reduce((validParams, paramKey) => {
return customURLParams.includes(paramKey)
/**
* Checks filter param value validity.
*
* Non-filter params are dropped.
*
* @param {Object} params Search params
* @param {Object} filters Filters configuration
*/
export const validFilterParams = (params, filters) => {
const filterParamNames = Object.values(filters).map(f => f.paramName);
const paramEntries = Object.entries(params);
return paramEntries.reduce((validParams, entry) => {
const paramName = entry[0];
const paramValue = entry[1];
return filterParamNames.includes(paramName)
? {
...validParams,
...validURLParamForExtendedData(paramKey, params, customURLParamToConfig),
...validURLParamForExtendedData(paramName, paramValue, filters),
}
: { ...validParams, [paramKey]: params[paramKey] };
: { ...validParams };
}, {});
};
/**
* Checks filter param value validity.
*
* Non-filter params are returned as they are.
*
* @param {Object} params Search params
* @param {Object} filters Filters configuration
*/
export const validURLParamsForExtendedData = (params, filters) => {
const filterParamNames = Object.values(filters).map(f => f.paramName);
const paramEntries = Object.entries(params);
return paramEntries.reduce((validParams, entry) => {
const paramName = entry[0];
const paramValue = entry[1];
return filterParamNames.includes(paramName)
? {
...validParams,
...validURLParamForExtendedData(paramName, paramValue, filters),
}
: { ...validParams, [paramName]: paramValue };
}, {});
};
// extract search parameters, including a custom URL params
// which are validated by mapping the values to marketplace custom config.
export const pickSearchParamsOnly = (params, customURLParamToConfig) => {
export const pickSearchParamsOnly = (params, filters) => {
const { address, origin, bounds, country, ...rest } = params || {};
const customURLParams = Object.keys(customURLParamToConfig);
const boundsMaybe = bounds ? { bounds } : {};
const originMaybe = config.sortSearchByDistance && origin ? { origin } : {};
const customSearchParamKeys = Object.keys(rest);
const customSearchParams = customSearchParamKeys.reduce((validParams, paramKey) => {
return customURLParams.includes(paramKey)
? { ...validParams, ...validURLParamForExtendedData(paramKey, rest, customURLParamToConfig) }
: { ...validParams };
}, {});
const filterParams = validFilterParams(rest, filters);
return {
...boundsMaybe,
...originMaybe,
...customSearchParams,
...filterParams,
};
};

View file

@ -0,0 +1,124 @@
import {
validURLParamForExtendedData,
validFilterParams,
validURLParamsForExtendedData,
pickSearchParamsOnly,
} from './SearchPage.helpers.js';
const urlParams = {
pub_category: 'smoke',
pub_amenities: 'towels,bathroom',
};
const filters = {
categoryFilter: {
paramName: 'pub_category',
options: [{ key: 'smoke' }, { key: 'wooden' }],
},
amenitiesFilter: {
paramName: 'pub_amenities',
options: [{ key: 'towels' }, { key: 'bathroom' }],
},
};
describe('SearchPage.helpers', () => {
describe('validURLParamForExtendedData', () => {
it('returns a valid parameter', () => {
const validParam = validURLParamForExtendedData('pub_category', 'smoke', filters);
expect(validParam).toEqual({ pub_category: 'smoke' });
});
it('takes empty params', () => {
const validParam = validURLParamForExtendedData('pub_category', '', filters);
expect(validParam).toEqual({});
});
it('drops an invalid param value', () => {
const validParam = validURLParamForExtendedData('pub_category', 'invalid', filters);
expect(validParam).toEqual({});
});
it('drops a param with invalid name', () => {
const validParam = validURLParamForExtendedData('pub_invalid', 'towels', filters);
expect(validParam).toEqual({});
});
});
describe('validFilterParams', () => {
it('returns valid parameters', () => {
const validParams = validFilterParams(urlParams, filters);
expect(validParams).toEqual(urlParams);
});
it('takes empty params', () => {
const validParams = validFilterParams({}, filters);
expect(validParams).toEqual({});
});
it('drops an invalid filter param value', () => {
const params = { pub_category: 'smoke', pub_amenities: 'invalid1,invalid2' };
const validParams = validFilterParams(params, filters);
expect(validParams).toEqual({ pub_category: 'smoke' });
});
it('drops non-filter params', () => {
const params = { pub_category: 'smoke', other_param: 'somevalue' };
const validParams = validFilterParams(params, filters);
expect(validParams).toEqual({ pub_category: 'smoke' });
});
});
describe('validURLParamsForExtendedData', () => {
it('returns valid parameters', () => {
const validParams = validURLParamsForExtendedData(urlParams, filters);
expect(validParams).toEqual(urlParams);
});
it('takes empty params', () => {
const validParams = validURLParamsForExtendedData({}, filters);
expect(validParams).toEqual({});
});
it('drops an invalid filter param value', () => {
const params = { pub_category: 'smoke', pub_amenities: 'invalid1,invalid2' };
const validParams = validURLParamsForExtendedData(params, filters);
expect(validParams).toEqual({ pub_category: 'smoke' });
});
it('returns non-filter params', () => {
const params = { pub_category: 'smoke', other_param: 'somevalue' };
const validParams = validURLParamsForExtendedData(params, filters);
expect(validParams).toEqual(params);
});
});
describe('pickSearchParamsOnly', () => {
it('returns search parameters', () => {
const params = {
address: 'address value',
origin: 'origin value',
bounds: 'bounds value',
country: 'country value',
};
const validParams = pickSearchParamsOnly(params, filters);
expect(validParams).toEqual({ bounds: 'bounds value' });
});
it('returns filter parameters', () => {
const validParams = pickSearchParamsOnly(urlParams, filters);
expect(validParams).toEqual(urlParams);
});
it('drops an invalid filter param value', () => {
const params = { pub_category: 'smoke', pub_amenities: 'invalid1,invalid2' };
const validParams = pickSearchParamsOnly(params, filters);
expect(validParams).toEqual({ pub_category: 'smoke' });
});
it('drops non-search params', () => {
const params = { pub_category: 'smoke', other_param: 'somevalue' };
const validParams = pickSearchParamsOnly(params, filters);
expect(validParams).toEqual({ pub_category: 'smoke' });
});
});
});

View file

@ -26,6 +26,7 @@ import { searchListings, searchMapListings, setActiveListing } from './SearchPag
import {
pickSearchParamsOnly,
validURLParamsForExtendedData,
validFilterParams,
createSearchResultSchema,
} from './SearchPage.helpers';
import MainPanel from './MainPanel';
@ -39,14 +40,6 @@ const MODAL_BREAKPOINT = 768; // Search is in modal on mobile layout
const SEARCH_WITH_MAP_DEBOUNCE = 300; // Little bit of debounce before search is initiated.
const BOUNDS_FIXED_PRECISION = 8;
const CATEGORY_URL_PARAM = 'pub_category';
const AMENITIES_URL_PARAM = 'pub_amenities';
const customURLParamToConfig = {
[CATEGORY_URL_PARAM]: 'categories',
[AMENITIES_URL_PARAM]: 'amenities',
};
export class SearchPageComponent extends Component {
constructor(props) {
super(props);
@ -65,11 +58,27 @@ export class SearchPageComponent extends Component {
this.modalOpenedBoundsChange = false;
this.searchMapListingsInProgress = false;
this.filters = this.filters.bind(this);
this.onIdle = debounce(this.onIdle.bind(this), SEARCH_WITH_MAP_DEBOUNCE);
this.onOpenMobileModal = this.onOpenMobileModal.bind(this);
this.onCloseMobileModal = this.onCloseMobileModal.bind(this);
}
filters() {
const { categories, amenities } = this.props;
return {
categoryFilter: {
paramName: 'pub_category',
options: categories,
},
amenitiesFilter: {
paramName: 'pub_amenities',
options: amenities,
},
};
}
componentWillReceiveProps(nextProps) {
if (!isEqual(this.props.location, nextProps.location)) {
// If no mapSearch url parameter is given, this is original location search
@ -118,7 +127,7 @@ export class SearchPageComponent extends Component {
bounds: viewportBounds,
country,
mapSearch: true,
...validURLParamsForExtendedData(rest, customURLParamToConfig),
...validFilterParams(rest, this.filters()),
};
this.viewportBounds = viewportBounds;
@ -155,8 +164,6 @@ export class SearchPageComponent extends Component {
searchInProgress,
searchListingsError,
searchParams,
categories,
amenities,
activeListingId,
onActivateListing,
} = this.props;
@ -166,16 +173,18 @@ export class SearchPageComponent extends Component {
latlngBounds: ['bounds'],
});
const filters = this.filters();
// urlQueryParams doesn't contain page specific url params
// like mapSearch, page or origin (origin depends on config.sortSearchByDistance)
const urlQueryParams = pickSearchParamsOnly(searchInURL, customURLParamToConfig);
const urlQueryParams = pickSearchParamsOnly(searchInURL, filters);
// Page transition might initially use values from previous search
const urlQueryString = stringify(urlQueryParams);
const paramsQueryString = stringify(pickSearchParamsOnly(searchParams, customURLParamToConfig));
const paramsQueryString = stringify(pickSearchParamsOnly(searchParams, filters));
const searchParamsAreInSync = urlQueryString === paramsQueryString;
const validQueryParams = validURLParamsForExtendedData(searchInURL, customURLParamToConfig);
const validQueryParams = validURLParamsForExtendedData(searchInURL, filters);
const isWindowDefined = typeof window !== 'undefined';
const isMobileLayout = isWindowDefined && window.innerWidth < MODAL_BREAKPOINT;
@ -227,8 +236,10 @@ export class SearchPageComponent extends Component {
pagination={pagination}
searchParamsForPagination={parse(location.search)}
showAsModalMaxWidth={MODAL_BREAKPOINT}
customURLParamToConfig={customURLParamToConfig}
primaryFilters={{ amenities, categories }}
primaryFilters={{
categoryFilter: filters.categoryFilter,
amenitiesFilter: filters.amenitiesFilter,
}}
/>
<ModalInMobile
className={css.mapPanel}

View file

@ -24,12 +24,6 @@ exports[`SearchPageComponent matches snapshot 1`] = `
<div>
<MainPanel
className={null}
customURLParamToConfig={
Object {
"pub_amenities": "amenities",
"pub_category": "categories",
}
}
listings={Array []}
onActivateListing={[Function]}
onCloseModal={[Function]}
@ -46,26 +40,32 @@ exports[`SearchPageComponent matches snapshot 1`] = `
}
primaryFilters={
Object {
"amenities": Array [
Object {
"key": "dog1",
"label": "Dog 1",
},
Object {
"key": "dog2",
"label": "Dog 2",
},
],
"categories": Array [
Object {
"key": "cat1",
"label": "Cat 1",
},
Object {
"key": "cat2",
"label": "Cat 2",
},
],
"amenitiesFilter": Object {
"options": Array [
Object {
"key": "dog1",
"label": "Dog 1",
},
Object {
"key": "dog2",
"label": "Dog 2",
},
],
"paramName": "pub_amenities",
},
"categoryFilter": Object {
"options": Array [
Object {
"key": "cat1",
"label": "Cat 1",
},
Object {
"key": "cat2",
"label": "Cat 2",
},
],
"paramName": "pub_category",
},
}
}
resultsCount={0}