mirror of
https://github.com/kingomarnajjar/flex-template-web.git
synced 2026-07-27 09:13:14 +10:00
Update filters configuration and validation
Changes the way filters are defined and how the filter params are validated.
This commit is contained in:
parent
649e073773
commit
ae2db17152
8 changed files with 355 additions and 176 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
124
src/containers/SearchPage/SearchPage.helpers.test.js
Normal file
124
src/containers/SearchPage/SearchPage.helpers.test.js
Normal 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' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue