diff --git a/CHANGELOG.md b/CHANGELOG.md index 67d16f6aa..8da878a75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,32 @@ https://github.com/sharetribe/flex-template-web/ ## Upcoming version 2020-XX-XX +## [v7.0.0] 2020-06-04 + +### Updates from upstream (FTW-daily v5.0.0) + +- [change] Streamlining filter setup. Everyone who customizes FTW-templates, needs to update filters + and unfortunately the related code has been spread out in multiple UI containers. + + Now, filters are more configurable through marketplace-custom-config.js. You can just add new + filter configs to `filters` array in there - and that should be enough for creating new filters + for extended data. + + If your are creating a totally new filter component, you can take it into use in a single file: + src/containers/SearchPage/FilterComponent.js + + In addition, we have renamed couple of container components: + + - SearchFilters -> SearchFiltersPrimary + - SearchFiltersPanel -> SearchFiltersSecondary (SearchFiltersMobile has kept its name.) + + SortBy filter's state is also tracked similarly as filters. From now on, the state is kept in + MainPanel and not in those 3 different UI containers. + + [#1296](https://github.com/sharetribe/ftw-daily/pull/1296) + +[v7.0.0]: https://github.com/sharetribe/flex-template-web/compare/v6.6.0...v7.0.0 + ## [v6.6.0] 2020-06-04 ### Updates from upstream diff --git a/package.json b/package.json index e95676c23..667f5b813 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "app", - "version": "6.6.0", + "version": "7.0.0", "private": true, "license": "Apache-2.0", "dependencies": { @@ -89,7 +89,7 @@ "config": "node scripts/config.js", "config-check": "node scripts/config.js --check", "dev-frontend": "sharetribe-scripts start", - "dev-backend": "DEV_API_SERVER_PORT=3500 nodemon server/apiServer.js", + "dev-backend": "export NODE_ENV=development DEV_API_SERVER_PORT=3500&&nodemon server/apiServer.js", "dev": "yarn run config-check&&concurrently --kill-others \"yarn run dev-frontend\" \"yarn run dev-backend\"", "build": "sharetribe-scripts build", "format": "prettier --write '**/*.{js,css}'", diff --git a/src/components/BookingDateRangeFilter/BookingDateRangeFilter.example.js b/src/components/BookingDateRangeFilter/BookingDateRangeFilter.example.js index ae112118b..1f0530be1 100644 --- a/src/components/BookingDateRangeFilter/BookingDateRangeFilter.example.js +++ b/src/components/BookingDateRangeFilter/BookingDateRangeFilter.example.js @@ -48,7 +48,7 @@ export const BookingDateRangeFilterPopup = { component: BookingDateRangeFilterWrapper, props: { id: 'BookingDateRangeFilterPopupExample', - urlParam: URL_PARAM, + queryParamNames: [URL_PARAM], liveEdit: false, showAsPopup: true, contentPlacementOffset: -14, @@ -62,7 +62,7 @@ export const BookingDateRangeFilterPlain = { component: BookingDateRangeFilterWrapper, props: { id: 'BookingDateRangeFilterPlainExample', - urlParam: URL_PARAM, + queryParamNames: [URL_PARAM], liveEdit: true, showAsPopup: false, contentPlacementOffset: -14, diff --git a/src/components/BookingDateRangeFilter/BookingDateRangeFilter.js b/src/components/BookingDateRangeFilter/BookingDateRangeFilter.js index fb3c77d76..3a44ffe81 100644 --- a/src/components/BookingDateRangeFilter/BookingDateRangeFilter.js +++ b/src/components/BookingDateRangeFilter/BookingDateRangeFilter.js @@ -1,10 +1,36 @@ import React, { Component } from 'react'; -import { bool, func, number, object, string } from 'prop-types'; +import { arrayOf, bool, func, node, number, object, string } from 'prop-types'; import { injectIntl, intlShape } from '../../util/reactIntl'; +import { parseDateFromISO8601, stringifyDateToISO8601 } from '../../util/dates'; import { FieldDateRangeController, FilterPopup, FilterPlain } from '../../components'; import css from './BookingDateRangeFilter.css'; +const getDatesQueryParamName = queryParamNames => { + return Array.isArray(queryParamNames) + ? queryParamNames[0] + : typeof queryParamNames === 'string' + ? queryParamNames + : 'dates'; +}; + +// Parse query parameter, which should look like "2020-05-28,2020-05-31" +const parseValue = value => { + const rawValuesFromParams = value ? value.split(',') : []; + const [startDate, endDate] = rawValuesFromParams.map(v => parseDateFromISO8601(v)); + return value && startDate && endDate ? { dates: { startDate, endDate } } : { dates: null }; +}; +// Format dateRange value for the query. It's given by FieldDateRangeInput: +// { dates: { startDate, endDate } } +const formatValue = (dateRange, queryParamName) => { + const hasDates = dateRange && dateRange.dates; + const { startDate, endDate } = hasDates ? dateRange.dates : {}; + const start = startDate ? stringifyDateToISO8601(startDate) : null; + const end = endDate ? stringifyDateToISO8601(endDate) : null; + const value = start && end ? `${start},${end}` : null; + return { [queryParamName]: value }; +}; + export class BookingDateRangeFilterComponent extends Component { constructor(props) { super(props); @@ -18,20 +44,25 @@ export class BookingDateRangeFilterComponent extends Component { className, rootClassName, showAsPopup, - initialValues: initialValuesRaw, + initialValues, id, contentPlacementOffset, onSubmit, - urlParam, + queryParamNames, + label, intl, ...rest } = this.props; - const isSelected = !!initialValuesRaw && !!initialValuesRaw.dates; - const initialValues = isSelected ? initialValuesRaw : { dates: null }; + const datesQueryParamName = getDatesQueryParamName(queryParamNames); + const initialDates = + initialValues && initialValues[datesQueryParamName] + ? parseValue(initialValues[datesQueryParamName]) + : { dates: null }; - const startDate = isSelected ? initialValues.dates.startDate : null; - const endDate = isSelected ? initialValues.dates.endDate : null; + const isSelected = !!initialDates.dates; + const startDate = isSelected ? initialDates.dates.startDate : null; + const endDate = isSelected ? initialDates.dates.endDate : null; const format = { month: 'short', @@ -48,6 +79,8 @@ export class BookingDateRangeFilterComponent extends Component { dates: `${formattedStartDate} - ${formattedEndDate}`, } ) + : label + ? label : intl.formatMessage({ id: 'BookingDateRangeFilter.labelPlain' }); const labelForPopup = isSelected @@ -57,8 +90,14 @@ export class BookingDateRangeFilterComponent extends Component { dates: `${formattedStartDate} - ${formattedEndDate}`, } ) + : label + ? label : intl.formatMessage({ id: 'BookingDateRangeFilter.labelPopup' }); + const handleSubmit = values => { + onSubmit(formatValue(values, datesQueryParamName)); + }; + const onClearPopupMaybe = this.popupControllerRef && this.popupControllerRef.onReset ? { onClear: () => this.popupControllerRef.onReset(null, null) } @@ -84,11 +123,10 @@ export class BookingDateRangeFilterComponent extends Component { id={`${id}.popup`} showAsPopup contentPlacementOffset={contentPlacementOffset} - onSubmit={onSubmit} + onSubmit={handleSubmit} {...onClearPopupMaybe} {...onCancelPopupMaybe} - initialValues={initialValues} - urlParam={urlParam} + initialValues={initialDates} {...rest} > { const tree = renderShallow( { const tree = renderShallow( { // Only show the minimum duration label for options whose key // matches the given param and that have the short label defined. @@ -20,6 +23,27 @@ const formatSelectedLabel = (minDurationOptions, minDuration, startDate, endDate : `${startDate} - ${endDate}`; }; +// Parse query parameter, which should look like "2020-05-28,2020-05-31" +const parseInitialValues = initialValues => { + const { dates, minDuration } = initialValues || {}; + const rawDateValuesFromParams = dates ? dates.split(',') : []; + const [startDate, endDate] = rawDateValuesFromParams.map(v => parseDateFromISO8601(v)); + const initialDates = + initialValues && startDate && endDate ? { dates: { startDate, endDate } } : { dates: null }; + const initialMinDuration = minDuration ? parseInt(minDuration, RADIX) : null; + return { ...initialDates, minDuration: initialMinDuration }; +}; +// Format dateRange value for the query. It's given by FieldDateRangeInput: +// { dates: { startDate, endDate } } +const formatValues = (values, dateQueryParam, minDurationParam) => { + const { startDate, endDate } = values && values[dateQueryParam] ? values[dateQueryParam] : {}; + const start = startDate ? stringifyDateToISO8601(startDate) : null; + const end = endDate ? stringifyDateToISO8601(endDate) : null; + const datesValue = start && end ? `${start},${end}` : null; + const minDurationValue = values && values[minDurationParam] ? values[minDurationParam] : null; + return { [dateQueryParam]: datesValue, [minDurationParam]: minDurationValue }; +}; + export class BookingDateRangeLengthFilterComponent extends Component { constructor(props) { super(props); @@ -41,22 +65,23 @@ export class BookingDateRangeLengthFilterComponent extends Component { rootClassName, dateRangeLengthFilter, showAsPopup, - initialDateValues: initialDateValuesRaw, - initialDurationValue, + initialValues: initialValuesRaw, id, contentPlacementOffset, onSubmit, - datesUrlParam, - durationUrlParam, + label, intl, ...rest } = this.props; - const isDatesSelected = !!initialDateValuesRaw && !!initialDateValuesRaw.dates; - const initialDateValues = isDatesSelected ? initialDateValuesRaw : { dates: null }; + const datesQueryParamName = 'dates'; + const minDurationQueryParamName = 'minDuration'; + + const parsedInitialValues = initialValuesRaw ? parseInitialValues(initialValuesRaw) : {}; + const { dates: initialDates, minDuration: initialMinDuration } = parsedInitialValues; + const { startDate, endDate } = initialDates || {}; - const startDate = isDatesSelected ? initialDateValues.dates.startDate : null; - const endDate = isDatesSelected ? initialDateValues.dates.endDate : null; + const isDatesSelected = !!initialDates && !!startDate && !!startDate; const format = { month: 'short', @@ -72,12 +97,14 @@ export class BookingDateRangeLengthFilterComponent extends Component { { dates: formatSelectedLabel( dateRangeLengthFilter.config.options, - initialDurationValue, + initialMinDuration, formattedStartDate, formattedEndDate ), } ) + : label + ? label : intl.formatMessage({ id: 'BookingDateRangeLengthFilter.labelPlain' }); const labelForPopup = isDatesSelected @@ -86,12 +113,14 @@ export class BookingDateRangeLengthFilterComponent extends Component { { dates: formatSelectedLabel( dateRangeLengthFilter.config.options, - initialDurationValue, + initialMinDuration, formattedStartDate, formattedEndDate ), } ) + : label + ? label : intl.formatMessage({ id: 'BookingDateRangeLengthFilter.labelPopup' }); const minDurationLabel = intl.formatMessage({ @@ -128,33 +157,34 @@ export class BookingDateRangeLengthFilterComponent extends Component { } : {}; - const handleSubmit = (param, values) => { + const handleSubmit = values => { this.setState({ selectedDates: null }); - onSubmit(values); + onSubmit(formatValues(values, datesQueryParamName, minDurationQueryParamName)); }; - const handleChange = (param, values) => { - this.setState({ selectedDates: values[datesUrlParam] }); + const handleChange = values => { + this.setState({ selectedDates: values[datesQueryParamName] }); }; - const datesSelected = !!(initialDateValues.dates || this.state.selectedDates); + const datesSelected = !!(initialDates || this.state.selectedDates); + const selectedDatesInState = this.state.selectedDates; const initialValues = { - ...initialDateValues, - minDuration: initialDurationValue, + dates: selectedDatesInState ? selectedDatesInState : initialDates, + minDuration: initialMinDuration, }; const fields = ( <> { this.popupControllerRef = node; }} /> {fields} @@ -202,7 +231,6 @@ export class BookingDateRangeLengthFilterComponent extends Component { onSubmit={handleSubmit} {...onClearPlainMaybe} initialValues={initialValues} - urlParam={datesUrlParam} {...rest} > {fields} @@ -217,8 +245,7 @@ BookingDateRangeLengthFilterComponent.defaultProps = { dateRangeLengthFitler: null, showAsPopup: true, liveEdit: false, - initialDateValues: null, - initialDurationValue: null, + initialValues: null, contentPlacementOffset: 0, }; @@ -229,11 +256,11 @@ BookingDateRangeLengthFilterComponent.propTypes = { dateRangeLengthFitler: propTypes.filterConfig, showAsPopup: bool, liveEdit: bool, - datesUrlParam: string.isRequired, - durationUrlParam: string.isRequired, onSubmit: func.isRequired, - initialDateValues: object, - initialDurationValue: number, + initialValues: shape({ + dates: string, + minDuration: string, + }), contentPlacementOffset: number, // form injectIntl diff --git a/src/components/EditListingDescriptionPanel/EditListingDescriptionPanel.js b/src/components/EditListingDescriptionPanel/EditListingDescriptionPanel.js index 3ac890066..ca62d6d8a 100644 --- a/src/components/EditListingDescriptionPanel/EditListingDescriptionPanel.js +++ b/src/components/EditListingDescriptionPanel/EditListingDescriptionPanel.js @@ -3,8 +3,9 @@ import { bool, func, object, string } from 'prop-types'; import classNames from 'classnames'; import { FormattedMessage } from '../../util/reactIntl'; import { ensureOwnListing } from '../../util/data'; -import { ListingLink } from '../../components'; +import { findOptionsForSelectFilter } from '../../util/search'; import { LISTING_STATE_DRAFT } from '../../util/types'; +import { ListingLink } from '../../components'; import { EditListingDescriptionForm } from '../../forms'; import config from '../../config'; @@ -45,6 +46,7 @@ const EditListingDescriptionPanel = props => { ); + const certificateOptions = findOptionsForSelectFilter('certificate', config.custom.filters); return (

{panelTitle}

@@ -68,7 +70,7 @@ const EditListingDescriptionPanel = props => { updated={panelUpdated} updateInProgress={updateInProgress} fetchErrors={errors} - certificate={config.custom.certificate} + certificateOptions={certificateOptions} />
); diff --git a/src/components/FilterPlain/FilterPlain.js b/src/components/FilterPlain/FilterPlain.js index 96a3e8400..3f568344a 100644 --- a/src/components/FilterPlain/FilterPlain.js +++ b/src/components/FilterPlain/FilterPlain.js @@ -17,18 +17,18 @@ class FilterPlainComponent extends Component { } handleChange(values) { - const { onSubmit, urlParam } = this.props; - onSubmit(urlParam, values); + const { onSubmit } = this.props; + onSubmit(values); } handleClear() { - const { onSubmit, onClear, urlParam } = this.props; + const { onSubmit, onClear } = this.props; if (onClear) { onClear(); } - onSubmit(urlParam, null); + onSubmit(null); } toggleIsOpen() { diff --git a/src/components/FilterPopup/FilterPopup.js b/src/components/FilterPopup/FilterPopup.js index 579bd37bb..3b85a8d17 100644 --- a/src/components/FilterPopup/FilterPopup.js +++ b/src/components/FilterPopup/FilterPopup.js @@ -28,38 +28,38 @@ class FilterPopup extends Component { } handleSubmit(values) { - const { onSubmit, urlParam } = this.props; + const { onSubmit } = this.props; this.setState({ isOpen: false }); - onSubmit(urlParam, values); + onSubmit(values); } handleChange(values) { - const { onChange, urlParam } = this.props; + const { onChange } = this.props; if (onChange) { - onChange(urlParam, values); + onChange(values); } } handleClear() { - const { onSubmit, onClear, urlParam } = this.props; + const { onSubmit, onClear } = this.props; this.setState({ isOpen: false }); if (onClear) { onClear(); } - onSubmit(urlParam, null); + onSubmit(null); } handleCancel() { - const { onSubmit, onCancel, initialValues, urlParam } = this.props; + const { onSubmit, onCancel, initialValues } = this.props; this.setState({ isOpen: false }); if (onCancel) { onCancel(); } - onSubmit(urlParam, initialValues); + onSubmit(initialValues); } handleBlur() { @@ -192,7 +192,6 @@ FilterPopup.propTypes = { className: string, popupClassName: string, id: string.isRequired, - urlParam: string.isRequired, onSubmit: func.isRequired, onChange: func, initialValues: object, diff --git a/src/components/KeywordFilter/KeywordFilter.example.js b/src/components/KeywordFilter/KeywordFilter.example.js index 05080320e..9d1af968b 100644 --- a/src/components/KeywordFilter/KeywordFilter.example.js +++ b/src/components/KeywordFilter/KeywordFilter.example.js @@ -5,9 +5,9 @@ import { stringify, parse } from '../../util/urlHelpers'; const URL_PARAM = 'keywords'; -const handleSubmit = (urlParam, values, history) => { +const handleSubmit = (values, history) => { console.log('Submitting values', values); - const queryParams = values ? `?${stringify({ [urlParam]: values })}` : ''; + const queryParams = values ? `?${stringify(values)}` : ''; history.push(`${window.location.pathname}${queryParams}`); }; @@ -16,15 +16,15 @@ const KeywordFilterPopup = withRouter(props => { const params = parse(location.search); const keyword = params[URL_PARAM]; - const initialValues = !!keyword ? keyword : ''; + const initialValues = !!keyword ? { [URL_PARAM]: keyword } : { [URL_PARAM]: null }; return ( handleSubmit(urlParam, values, history)} + onSubmit={values => handleSubmit(values, history)} showAsPopup={true} liveEdit={false} initialValues={initialValues} @@ -44,16 +44,16 @@ const KeywordFilterPlain = withRouter(props => { const params = parse(location.search); const keyword = params[URL_PARAM]; - const initialValues = !!keyword ? keyword : ''; + const initialValues = !!keyword ? { [URL_PARAM]: keyword } : { [URL_PARAM]: null }; return ( { - handleSubmit(urlParam, values, history); + onSubmit={values => { + handleSubmit(values, history); }} showAsPopup={false} liveEdit={true} diff --git a/src/components/KeywordFilter/KeywordFilter.js b/src/components/KeywordFilter/KeywordFilter.js index 80f8728d0..051740391 100644 --- a/src/components/KeywordFilter/KeywordFilter.js +++ b/src/components/KeywordFilter/KeywordFilter.js @@ -1,5 +1,5 @@ import React, { Component } from 'react'; -import { func, number, string } from 'prop-types'; +import { arrayOf, func, number, shape, string } from 'prop-types'; import classNames from 'classnames'; import { injectIntl, intlShape } from '../../util/reactIntl'; import debounce from 'lodash/debounce'; @@ -13,6 +13,14 @@ const DEBOUNCE_WAIT_TIME = 600; // Short search queries (e.g. 2 letters) have a longer timeout before search is made const TIMEOUT_FOR_SHORT_QUERIES = 2000; +const getKeywordQueryParam = queryParamNames => { + return Array.isArray(queryParamNames) + ? queryParamNames[0] + : typeof queryParamNames === 'string' + ? queryParamNames + : 'keywords'; +}; + class KeywordFilter extends Component { constructor(props) { super(props); @@ -62,7 +70,7 @@ class KeywordFilter extends Component { initialValues, contentPlacementOffset, onSubmit, - urlParam, + queryParamNames, intl, showAsPopup, ...rest @@ -70,15 +78,20 @@ class KeywordFilter extends Component { const classes = classNames(rootClassName || css.root, className); - const hasInitialValues = !!initialValues && initialValues.length > 0; + const urlParam = getKeywordQueryParam(queryParamNames); + const hasInitialValues = + !!initialValues && !!initialValues[urlParam] && initialValues[urlParam].length > 0; const labelForPopup = hasInitialValues - ? intl.formatMessage({ id: 'KeywordFilter.labelSelected' }, { labelText: initialValues }) + ? intl.formatMessage( + { id: 'KeywordFilter.labelSelected' }, + { labelText: initialValues[urlParam] } + ) : label; const labelForPlain = hasInitialValues ? intl.formatMessage( { id: 'KeywordFilterPlainForm.labelSelected' }, - { labelText: initialValues } + { labelText: initialValues[urlParam] } ) : label; @@ -90,11 +103,11 @@ class KeywordFilter extends Component { // pass the initial values with the name key so that // they can be passed to the correct field - const namedInitialValues = { [name]: initialValues }; + const namedInitialValues = { [name]: initialValues[urlParam] }; - const handleSubmit = (urlParam, values) => { + const handleSubmit = values => { const usedValue = values ? values[name] : values; - onSubmit(urlParam, usedValue); + onSubmit({ [urlParam]: usedValue }); }; const debouncedSubmit = debounce(handleSubmit, DEBOUNCE_WAIT_TIME, { @@ -102,22 +115,22 @@ class KeywordFilter extends Component { trailing: true, }); // Use timeout for shart queries and debounce for queries with any length - const handleChangeWithDebounce = (urlParam, values) => { - // handleSubmit gets urlParam and values as params. + const handleChangeWithDebounce = values => { + // handleSubmit gets values as params. // If this field ("keyword") is short, create timeout const hasKeywordValue = values && values[name]; const keywordValue = hasKeywordValue ? values && values[name] : ''; - if (urlParam && (!hasKeywordValue || (hasKeywordValue && keywordValue.length >= 3))) { + if (!hasKeywordValue || (hasKeywordValue && keywordValue.length >= 3)) { if (this.shortKeywordTimeout) { window.clearTimeout(this.shortKeywordTimeout); } - return debouncedSubmit(urlParam, values); + return debouncedSubmit(values); } else { this.shortKeywordTimeout = window.setTimeout(() => { // if mobileInputRef exists, use the most up-to-date value from there return this.mobileInputRef && this.mobileInputRef.current - ? handleSubmit(urlParam, { ...values, [name]: this.mobileInputRef.current.value }) - : handleSubmit(urlParam, values); + ? handleSubmit({ ...values, [name]: this.mobileInputRef.current.value }) + : handleSubmit(values); }, TIMEOUT_FOR_SHORT_QUERIES); } }; @@ -143,7 +156,6 @@ class KeywordFilter extends Component { contentPlacementOffset={contentPlacementOffset} onSubmit={handleSubmit} initialValues={namedInitialValues} - urlParam={urlParam} keepDirtyOnReinitialize {...rest} > @@ -169,7 +181,6 @@ class KeywordFilter extends Component { onSubmit={handleChangeWithDebounce} onClear={handleClear} initialValues={namedInitialValues} - urlParam={urlParam} {...rest} >
@@ -201,10 +212,12 @@ KeywordFilter.propTypes = { className: string, id: string.isRequired, name: string.isRequired, - urlParam: string.isRequired, + queryParamNames: arrayOf(string).isRequired, label: string.isRequired, onSubmit: func.isRequired, - initialValues: string, + initialValues: shape({ + keyword: string, + }), contentPlacementOffset: number, // form injectIntl diff --git a/src/components/ListingCard/ListingCard.js b/src/components/ListingCard/ListingCard.js index e6278189d..1f75dc0e5 100644 --- a/src/components/ListingCard/ListingCard.js +++ b/src/components/ListingCard/ListingCard.js @@ -7,6 +7,7 @@ import { LINE_ITEM_DAY, LINE_ITEM_NIGHT, propTypes } from '../../util/types'; import { formatMoney } from '../../util/currency'; import { ensureListing } from '../../util/data'; import { richText } from '../../util/richText'; +import { findOptionsForSelectFilter } from '../../util/search'; import { createSlug } from '../../util/urlHelpers'; import config from '../../config'; import { NamedLink, ResponsiveImage } from '../../components'; @@ -34,8 +35,8 @@ const priceData = (price, intl) => { return {}; }; -const getCertificateInfo = (certificateConfig, key) => { - return certificateConfig.find(c => c.key === key); +const getCertificateInfo = (certificateOptions, key) => { + return certificateOptions.find(c => c.key === key); }; class ListingImage extends Component { @@ -52,7 +53,7 @@ export const ListingCardComponent = props => { intl, listing, renderSizes, - certificateConfig, + filtersConfig, setActiveListing, } = props; const classes = classNames(rootClassName || css.root, className); @@ -63,8 +64,9 @@ export const ListingCardComponent = props => { const firstImage = currentListing.images && currentListing.images.length > 0 ? currentListing.images[0] : null; + const certificateOptions = findOptionsForSelectFilter('certificate', filtersConfig); const certificate = publicData - ? getCertificateInfo(certificateConfig, publicData.certificate) + ? getCertificateInfo(certificateOptions, publicData.certificate) : null; const { formattedPrice, priceTitle } = priceData(price, intl); @@ -126,14 +128,14 @@ ListingCardComponent.defaultProps = { className: null, rootClassName: null, renderSizes: null, - certificateConfig: config.custom.certificate, + filtersConfig: config.custom.filters, setActiveListing: () => null, }; ListingCardComponent.propTypes = { className: string, rootClassName: string, - certificateConfig: array, + filtersConfig: array, intl: intlShape.isRequired, listing: propTypes.listing.isRequired, diff --git a/src/components/PriceFilter/PriceFilter.example.js b/src/components/PriceFilter/PriceFilter.example.js index 029ad18dc..15304dc4b 100644 --- a/src/components/PriceFilter/PriceFilter.example.js +++ b/src/components/PriceFilter/PriceFilter.example.js @@ -5,15 +5,10 @@ import { stringify, parse } from '../../util/urlHelpers'; import PriceFilter from './PriceFilter'; const URL_PARAM = 'pub_price'; -const RADIX = 10; // Helper for submitting example -const handleSubmit = (urlParam, values, history) => { - const { minPrice, maxPrice } = values || {}; - const queryParams = - minPrice != null && maxPrice != null - ? `?${stringify({ [urlParam]: [minPrice, maxPrice].join(',') })}` - : ''; +const handleSubmit = (values, history) => { + const queryParams = values ? `?${stringify(values)}` : ''; history.push(`${window.location.pathname}${queryParams}`); }; @@ -22,21 +17,15 @@ const PriceFilterWrapper = withRouter(props => { const params = parse(location.search); const price = params[URL_PARAM]; - const valuesFromParams = !!price ? price.split(',').map(v => Number.parseInt(v, RADIX)) : []; - const initialValues = !!price - ? { - minPrice: valuesFromParams[0], - maxPrice: valuesFromParams[1], - } - : null; + const initialValues = { [URL_PARAM]: !!price ? price : null }; return ( { + onSubmit={values => { console.log('Submit PriceFilterForm with (unformatted) values:', values); - handleSubmit(urlParam, values, history); + handleSubmit(values, history); }} /> ); @@ -46,7 +35,7 @@ export const PriceFilterPopup = { component: PriceFilterWrapper, props: { id: 'PriceFilterPopupExample', - urlParam: URL_PARAM, + queryParamNames: [URL_PARAM], min: 0, max: 1000, step: 5, @@ -56,14 +45,14 @@ export const PriceFilterPopup = { // initialValues: handled inside wrapper // onSubmit: handled inside wrapper }, - group: 'misc', + group: 'filters', }; export const PriceFilterPlain = { component: PriceFilterWrapper, props: { id: 'PriceFilterPlainExample', - urlParam: URL_PARAM, + queryParamNames: [URL_PARAM], min: 0, max: 1000, step: 5, @@ -73,5 +62,5 @@ export const PriceFilterPlain = { // initialValues: handled inside wrapper // onSubmit: handled inside wrapper }, - group: 'misc', + group: 'filters', }; diff --git a/src/components/PriceFilter/PriceFilterPlain.js b/src/components/PriceFilter/PriceFilterPlain.js index ce7d85534..b19703d75 100644 --- a/src/components/PriceFilter/PriceFilterPlain.js +++ b/src/components/PriceFilter/PriceFilterPlain.js @@ -1,5 +1,5 @@ import React, { Component } from 'react'; -import { func, number, shape, string } from 'prop-types'; +import { arrayOf, func, node, number, shape, string } from 'prop-types'; import classNames from 'classnames'; import { FormattedMessage, injectIntl, intlShape } from '../../util/reactIntl'; import { propTypes } from '../../util/types'; @@ -10,6 +10,33 @@ import { PriceFilterForm } from '../../forms'; import css from './PriceFilterPlain.css'; +const RADIX = 10; + +const getPriceQueryParamName = queryParamNames => { + return Array.isArray(queryParamNames) + ? queryParamNames[0] + : typeof queryParamNames === 'string' + ? queryParamNames + : 'price'; +}; + +// Parse value, which should look like "0,1000" +const parse = priceRange => { + const [minPrice, maxPrice] = !!priceRange + ? priceRange.split(',').map(v => Number.parseInt(v, RADIX)) + : []; + // Note: we compare to null, because 0 as minPrice is falsy in comparisons. + return !!priceRange && minPrice != null && maxPrice != null ? { minPrice, maxPrice } : null; +}; + +// Format value, which should look like { minPrice, maxPrice } +const format = (range, queryParamName) => { + const { minPrice, maxPrice } = range || {}; + // Note: we compare to null, because 0 as minPrice is falsy in comparisons. + const value = minPrice != null && maxPrice != null ? `${minPrice},${maxPrice}` : null; + return { [queryParamName]: value }; +}; + class PriceFilterPlainComponent extends Component { constructor(props) { super(props); @@ -21,13 +48,15 @@ class PriceFilterPlainComponent extends Component { } handleChange(values) { - const { onSubmit, urlParam } = this.props; - onSubmit(urlParam, values); + const { onSubmit, queryParamNames } = this.props; + const priceQueryParamName = getPriceQueryParamName(queryParamNames); + onSubmit(format(values, priceQueryParamName)); } handleClear() { - const { onSubmit, urlParam } = this.props; - onSubmit(urlParam, null); + const { onSubmit, queryParamNames } = this.props; + const priceQueryParamName = getPriceQueryParamName(queryParamNames); + onSubmit(format(null, priceQueryParamName)); } toggleIsOpen() { @@ -39,6 +68,8 @@ class PriceFilterPlainComponent extends Component { rootClassName, className, id, + label, + queryParamNames, initialValues, min, max, @@ -47,7 +78,10 @@ class PriceFilterPlainComponent extends Component { currencyConfig, } = this.props; const classes = classNames(rootClassName || css.root, className); - const { minPrice, maxPrice } = initialValues || {}; + + const priceQueryParam = getPriceQueryParamName(queryParamNames); + const initialPrice = initialValues ? parse(initialValues[priceQueryParam]) : {}; + const { minPrice, maxPrice } = initialPrice || {}; const hasValue = value => value != null; const hasInitialValues = initialValues && hasValue(minPrice) && hasValue(maxPrice); @@ -61,6 +95,8 @@ class PriceFilterPlainComponent extends Component { maxPrice: formatCurrencyMajorUnit(intl, currencyConfig.currency, maxPrice), } ) + : label + ? label : intl.formatMessage({ id: 'PriceFilter.label' }); return ( @@ -76,7 +112,7 @@ class PriceFilterPlainComponent extends Component {
{ @@ -106,11 +142,11 @@ PriceFilterPlainComponent.propTypes = { rootClassName: string, className: string, id: string.isRequired, - urlParam: string.isRequired, + label: node, + queryParamNames: arrayOf(string).isRequired, onSubmit: func.isRequired, initialValues: shape({ - minPrice: number.isRequired, - maxPrice: number.isRequired, + price: string, }), min: number.isRequired, max: number.isRequired, diff --git a/src/components/PriceFilter/PriceFilterPopup.js b/src/components/PriceFilter/PriceFilterPopup.js index b707cc3d5..c9931969a 100644 --- a/src/components/PriceFilter/PriceFilterPopup.js +++ b/src/components/PriceFilter/PriceFilterPopup.js @@ -1,5 +1,5 @@ import React, { Component } from 'react'; -import { func, number, shape, string } from 'prop-types'; +import { arrayOf, func, node, number, shape, string } from 'prop-types'; import classNames from 'classnames'; import { injectIntl, intlShape } from '../../util/reactIntl'; import { propTypes } from '../../util/types'; @@ -10,6 +10,32 @@ import { PriceFilterForm } from '../../forms'; import css from './PriceFilterPopup.css'; const KEY_CODE_ESCAPE = 27; +const RADIX = 10; + +const getPriceQueryParamName = queryParamNames => { + return Array.isArray(queryParamNames) + ? queryParamNames[0] + : typeof queryParamNames === 'string' + ? queryParamNames + : 'price'; +}; + +// Parse value, which should look like "0,1000" +const parse = priceRange => { + const [minPrice, maxPrice] = !!priceRange + ? priceRange.split(',').map(v => Number.parseInt(v, RADIX)) + : []; + // Note: we compare to null, because 0 as minPrice is falsy in comparisons. + return !!priceRange && minPrice != null && maxPrice != null ? { minPrice, maxPrice } : null; +}; + +// Format value, which should look like { minPrice, maxPrice } +const format = (range, queryParamName) => { + const { minPrice, maxPrice } = range || {}; + // Note: we compare to null, because 0 as minPrice is falsy in comparisons. + const value = minPrice != null && maxPrice != null ? `${minPrice},${maxPrice}` : null; + return { [queryParamName]: value }; +}; class PriceFilterPopup extends Component { constructor(props) { @@ -29,21 +55,23 @@ class PriceFilterPopup extends Component { } handleSubmit(values) { - const { onSubmit, urlParam } = this.props; + const { onSubmit, queryParamNames } = this.props; this.setState({ isOpen: false }); - onSubmit(urlParam, values); + const priceQueryParamName = getPriceQueryParamName(queryParamNames); + onSubmit(format(values, priceQueryParamName)); } handleClear() { - const { onSubmit, urlParam } = this.props; + const { onSubmit, queryParamNames } = this.props; this.setState({ isOpen: false }); - onSubmit(urlParam, null); + const priceQueryParamName = getPriceQueryParamName(queryParamNames); + onSubmit(format(null, priceQueryParamName)); } handleCancel() { - const { onSubmit, initialValues, urlParam } = this.props; + const { onSubmit, initialValues } = this.props; this.setState({ isOpen: false }); - onSubmit(urlParam, initialValues); + onSubmit(initialValues); } handleBlur(event) { @@ -97,6 +125,8 @@ class PriceFilterPopup extends Component { rootClassName, className, id, + label, + queryParamNames, initialValues, min, max, @@ -105,12 +135,16 @@ class PriceFilterPopup extends Component { currencyConfig, } = this.props; const classes = classNames(rootClassName || css.root, className); - const { minPrice, maxPrice } = initialValues || {}; + + const priceQueryParam = getPriceQueryParamName(queryParamNames); + const initialPrice = + initialValues && initialValues[priceQueryParam] ? parse(initialValues[priceQueryParam]) : {}; + const { minPrice, maxPrice } = initialPrice || {}; const hasValue = value => value != null; const hasInitialValues = initialValues && hasValue(minPrice) && hasValue(maxPrice); - const label = hasInitialValues + const currentLabel = hasInitialValues ? intl.formatMessage( { id: 'PriceFilter.labelSelectedButton' }, { @@ -118,6 +152,8 @@ class PriceFilterPopup extends Component { maxPrice: formatCurrencyMajorUnit(intl, currencyConfig.currency, maxPrice), } ) + : label + ? label : intl.formatMessage({ id: 'PriceFilter.label' }); const labelStyles = hasInitialValues ? css.labelSelected : css.label; @@ -133,11 +169,11 @@ class PriceFilterPopup extends Component { }} > { - return queryParams[paramName]; -}; - -const initialPriceRangeValue = (queryParams, paramName) => { - const price = queryParams[paramName]; - const valuesFromParams = !!price ? price.split(',').map(v => Number.parseInt(v, RADIX)) : []; - - return !!price && valuesFromParams.length === 2 - ? { - minPrice: valuesFromParams[0], - maxPrice: valuesFromParams[1], - } - : null; -}; - -const initialDateRangeValue = (queryParams, paramName) => { - const dates = queryParams[paramName]; - const rawValuesFromParams = !!dates ? dates.split(',') : []; - const valuesFromParams = rawValuesFromParams.map(v => parseDateFromISO8601(v)); - const initialValues = - !!dates && valuesFromParams.length === 2 - ? { - dates: { startDate: valuesFromParams[0], endDate: valuesFromParams[1] }, - } - : { dates: null }; - - return initialValues; -}; - -const SearchFiltersComponent = props => { - const { - rootClassName, - className, - urlQueryParams, - sort, - listingsAreLoaded, - resultsCount, - searchInProgress, - priceFilter, - dateRangeLengthFilter, - keywordFilter, - isSearchFiltersPanelOpen, - toggleSearchFiltersPanel, - searchFiltersPanelSelectedCount, - history, - intl, - } = props; - - const hasNoResult = listingsAreLoaded && resultsCount === 0; - const classes = classNames(rootClassName || css.root, className); - - const keywordLabel = intl.formatMessage({ - id: 'SearchFilters.keywordLabel', - }); - - const initialPriceRange = priceFilter - ? initialPriceRangeValue(urlQueryParams, priceFilter.paramName) - : null; - - const initialDates = dateRangeLengthFilter - ? initialDateRangeValue(urlQueryParams, dateRangeLengthFilter.paramName) - : null; - - const initialMinDuration = dateRangeLengthFilter - ? initialValue(urlQueryParams, dateRangeLengthFilter.minDurationParamName) - : null; - - const initialKeyword = keywordFilter - ? initialValue(urlQueryParams, keywordFilter.paramName) - : null; - - const isKeywordFilterActive = !!initialKeyword; - - const handlePrice = (urlParam, range) => { - const { minPrice, maxPrice } = range || {}; - const queryParams = - minPrice != null && maxPrice != null - ? { ...urlQueryParams, [urlParam]: `${minPrice},${maxPrice}` } - : omit(urlQueryParams, urlParam); - - history.push(createResourceLocatorString('SearchPage', routeConfiguration(), {}, queryParams)); - }; - - const handleKeyword = (urlParam, values) => { - const queryParams = values - ? { ...urlQueryParams, [urlParam]: values } - : omit(urlQueryParams, urlParam); - - history.push(createResourceLocatorString('SearchPage', routeConfiguration(), {}, queryParams)); - }; - - const priceFilterElement = priceFilter ? ( - - ) : null; - - const handleDateRangeLength = values => { - const hasDates = values && values[dateRangeLengthFilter.paramName]; - const { startDate, endDate } = hasDates ? values[dateRangeLengthFilter.paramName] : {}; - const start = startDate ? stringifyDateToISO8601(startDate) : null; - const end = endDate ? stringifyDateToISO8601(endDate) : null; - const minDuration = - hasDates && values && values[dateRangeLengthFilter.minDurationParamName] - ? values[dateRangeLengthFilter.minDurationParamName] - : null; - - const restParams = omit( - urlQueryParams, - dateRangeLengthFilter.paramName, - dateRangeLengthFilter.minDurationParamName - ); - - const datesMaybe = - start != null && end != null ? { [dateRangeLengthFilter.paramName]: `${start},${end}` } : {}; - const minDurationMaybe = minDuration - ? { [dateRangeLengthFilter.minDurationParamName]: minDuration } - : {}; - - const queryParams = { - ...datesMaybe, - ...minDurationMaybe, - ...restParams, - }; - history.push(createResourceLocatorString('SearchPage', routeConfiguration(), {}, queryParams)); - }; - - const dateRangeLengthFilterElement = - dateRangeLengthFilter && dateRangeLengthFilter.config.active ? ( - - ) : null; - - const keywordFilterElement = - keywordFilter && keywordFilter.config.active ? ( - - ) : null; - - const toggleSearchFiltersPanelButtonClasses = - isSearchFiltersPanelOpen || searchFiltersPanelSelectedCount > 0 - ? css.searchFiltersPanelOpen - : css.searchFiltersPanelClosed; - const toggleSearchFiltersPanelButton = toggleSearchFiltersPanel ? ( - - ) : null; - - const handleSortBy = (urlParam, values) => { - const queryParams = values - ? { ...urlQueryParams, [urlParam]: values } - : omit(urlQueryParams, urlParam); - - history.push(createResourceLocatorString('SearchPage', routeConfiguration(), {}, queryParams)); - }; - - const sortBy = config.custom.sortConfig.active ? ( - - ) : null; - - return ( -
-
- {listingsAreLoaded ? ( -
- - - -
- ) : null} - {sortBy} -
- -
- {dateRangeLengthFilterElement} - {priceFilterElement} - {keywordFilterElement} - {toggleSearchFiltersPanelButton} -
- - {hasNoResult ? ( -
- -
- ) : null} - - {searchInProgress ? ( -
- -
- ) : null} -
- ); -}; - -SearchFiltersComponent.defaultProps = { - rootClassName: null, - className: null, - resultsCount: null, - searchingInProgress: false, - priceFilter: null, - dateRangeLengthFilter: null, - isSearchFiltersPanelOpen: false, - toggleSearchFiltersPanel: null, - searchFiltersPanelSelectedCount: 0, -}; - -SearchFiltersComponent.propTypes = { - rootClassName: string, - className: string, - urlQueryParams: object.isRequired, - listingsAreLoaded: bool.isRequired, - resultsCount: number, - searchingInProgress: bool, - onManageDisableScrolling: func.isRequired, - priceFilter: propTypes.filterConfig, - dateRangeLengthFilter: propTypes.filterConfig, - isSearchFiltersPanelOpen: bool, - toggleSearchFiltersPanel: func, - searchFiltersPanelSelectedCount: number, - - // from withRouter - history: shape({ - push: func.isRequired, - }).isRequired, - - // from injectIntl - intl: intlShape.isRequired, -}; - -const SearchFilters = compose( - withRouter, - injectIntl -)(SearchFiltersComponent); - -export default SearchFilters; diff --git a/src/components/SearchFiltersMobile/SearchFiltersMobile.css b/src/components/SearchFiltersMobile/SearchFiltersMobile.css index e649092e6..2514b671e 100644 --- a/src/components/SearchFiltersMobile/SearchFiltersMobile.css +++ b/src/components/SearchFiltersMobile/SearchFiltersMobile.css @@ -60,23 +60,6 @@ border-radius: 4px; } -.sortBy { - margin-right: 9px; -} - -.sortByMenuLabel { - @apply --marketplaceButtonStylesSecondary; - @apply --marketplaceTinyFontStyles; - font-weight: var(--fontWeightBold); - - height: 35px; - min-height: 35px; - padding: 0 18px; - margin: 0; - border-radius: 4px; - white-space: nowrap; -} - .mapIcon { /* Font */ @apply --marketplaceTinyFontStyles; diff --git a/src/components/SearchFiltersMobile/SearchFiltersMobile.js b/src/components/SearchFiltersMobile/SearchFiltersMobile.js index 7340fbe69..ed17f01a3 100644 --- a/src/components/SearchFiltersMobile/SearchFiltersMobile.js +++ b/src/components/SearchFiltersMobile/SearchFiltersMobile.js @@ -1,30 +1,14 @@ import React, { Component } from 'react'; -import { object, string, bool, number, func, shape, array } from 'prop-types'; +import { bool, func, object, node, number, shape, string } from 'prop-types'; import classNames from 'classnames'; import { FormattedMessage, injectIntl, intlShape } from '../../util/reactIntl'; import { withRouter } from 'react-router-dom'; -import omit from 'lodash/omit'; - -import config from '../../config'; import routeConfiguration from '../../routeConfiguration'; import { createResourceLocatorString } from '../../util/routes'; -import { parseDateFromISO8601, stringifyDateToISO8601 } from '../../util/dates'; -import { - BookingDateRangeLengthFilter, - ModalInMobile, - Button, - KeywordFilter, - PriceFilter, - SelectSingleFilter, - SelectMultipleFilter, - SortBy, -} from '../../components'; -import { propTypes } from '../../util/types'; +import { ModalInMobile, Button } from '../../components'; import css from './SearchFiltersMobile.css'; -const RADIX = 10; - class SearchFiltersMobileComponent extends Component { constructor(props) { super(props); @@ -34,16 +18,6 @@ class SearchFiltersMobileComponent extends Component { this.cancelFilters = this.cancelFilters.bind(this); this.closeFilters = this.closeFilters.bind(this); this.resetAll = this.resetAll.bind(this); - this.handleSelectSingle = this.handleSelectSingle.bind(this); - this.handleSelectMultiple = this.handleSelectMultiple.bind(this); - this.handlePrice = this.handlePrice.bind(this); - this.handleKeyword = this.handleKeyword.bind(this); - this.handleSortBy = this.handleSortBy.bind(this); - this.handleDateRangeLength = this.handleDateRangeLength.bind(this); - this.initialValue = this.initialValue.bind(this); - this.initialValues = this.initialValues.bind(this); - this.initialPriceRangeValue = this.initialPriceRangeValue.bind(this); - this.initialDateRangeValue = this.initialDateRangeValue.bind(this); } // Open filters modal, set the initial parameters to current ones @@ -75,96 +49,9 @@ class SearchFiltersMobileComponent extends Component { this.setState({ isFiltersOpenOnMobile: false }); } - handleSelectSingle(urlParam, option) { - const { urlQueryParams, history } = this.props; - - // query parameters after selecting the option - // if no option is passed, clear the selection for the filter - const queryParams = option - ? { ...urlQueryParams, [urlParam]: option } - : omit(urlQueryParams, urlParam); - - history.push(createResourceLocatorString('SearchPage', routeConfiguration(), {}, queryParams)); - } - - handleSelectMultiple(urlParam, options) { - const { urlQueryParams, history } = this.props; - - const queryParams = - options && options.length > 0 - ? { ...urlQueryParams, [urlParam]: options.join(',') } - : omit(urlQueryParams, urlParam); - - history.push(createResourceLocatorString('SearchPage', routeConfiguration(), {}, queryParams)); - } - - handlePrice(urlParam, range) { - const { urlQueryParams, history } = this.props; - const { minPrice, maxPrice } = range || {}; - const queryParams = - minPrice != null && maxPrice != null - ? { ...urlQueryParams, [urlParam]: `${minPrice},${maxPrice}` } - : omit(urlQueryParams, urlParam); - - history.push(createResourceLocatorString('SearchPage', routeConfiguration(), {}, queryParams)); - } - - handleKeyword(urlParam, keywords) { - const { urlQueryParams, history } = this.props; - const queryParams = urlParam - ? { ...urlQueryParams, [urlParam]: keywords } - : omit(urlQueryParams, urlParam); - - history.push(createResourceLocatorString('SearchPage', routeConfiguration(), {}, queryParams)); - } - - handleSortBy(urlParam, sort) { - const { urlQueryParams, history } = this.props; - const queryParams = urlParam - ? { ...urlQueryParams, [urlParam]: sort } - : omit(urlQueryParams, urlParam); - - history.push(createResourceLocatorString('SearchPage', routeConfiguration(), {}, queryParams)); - } - - handleDateRangeLength(values) { - const { urlQueryParams, history, dateRangeLengthFilter } = this.props; - - const hasDates = values && values[dateRangeLengthFilter.paramName]; - const { startDate, endDate } = hasDates ? values[dateRangeLengthFilter.paramName] : {}; - const start = startDate ? stringifyDateToISO8601(startDate) : null; - const end = endDate ? stringifyDateToISO8601(endDate) : null; - const minDuration = - hasDates && values && values[dateRangeLengthFilter.minDurationParamName] - ? values[dateRangeLengthFilter.minDurationParamName] - : null; - - const restParams = omit( - urlQueryParams, - dateRangeLengthFilter.paramName, - dateRangeLengthFilter.minDurationParamName - ); - - const datesMaybe = - start != null && end != null ? { [dateRangeLengthFilter.paramName]: `${start},${end}` } : {}; - const minDurationMaybe = minDuration - ? { [dateRangeLengthFilter.minDurationParamName]: minDuration } - : {}; - - const queryParams = { - ...datesMaybe, - ...minDurationMaybe, - ...restParams, - }; - history.push(createResourceLocatorString('SearchPage', routeConfiguration(), {}, queryParams)); - } - // Reset all filter query parameters resetAll(e) { - const { urlQueryParams, history, filterParamNames } = this.props; - - const queryParams = omit(urlQueryParams, filterParamNames); - history.push(createResourceLocatorString('SearchPage', routeConfiguration(), {}, queryParams)); + this.props.resetAll(e); // blur event target if event is passed if (e && e.currentTarget) { @@ -172,50 +59,12 @@ class SearchFiltersMobileComponent extends Component { } } - // resolve initial value for a single value filter - initialValue(paramName) { - return this.props.urlQueryParams[paramName]; - } - - // resolve initial values for a multi value filter - initialValues(paramName) { - const urlQueryParams = this.props.urlQueryParams; - return !!urlQueryParams[paramName] ? urlQueryParams[paramName].split(',') : []; - } - - initialPriceRangeValue(paramName) { - const urlQueryParams = this.props.urlQueryParams; - const price = urlQueryParams[paramName]; - const valuesFromParams = !!price ? price.split(',').map(v => Number.parseInt(v, RADIX)) : []; - - return !!price && valuesFromParams.length === 2 - ? { - minPrice: valuesFromParams[0], - maxPrice: valuesFromParams[1], - } - : null; - } - - initialDateRangeValue(paramName) { - const urlQueryParams = this.props.urlQueryParams; - const dates = urlQueryParams[paramName]; - const rawValuesFromParams = !!dates ? dates.split(',') : []; - const valuesFromParams = rawValuesFromParams.map(v => parseDateFromISO8601(v)); - const initialValues = - !!dates && valuesFromParams.length === 2 - ? { - dates: { startDate: valuesFromParams[0], endDate: valuesFromParams[1] }, - } - : { dates: null }; - - return initialValues; - } - render() { const { rootClassName, className, - sort, + children, + sortByComponent, listingsAreLoaded, resultsCount, searchInProgress, @@ -223,21 +72,16 @@ class SearchFiltersMobileComponent extends Component { onMapIconClick, onManageDisableScrolling, selectedFiltersCount, - certificateFilter, - yogaStylesFilter, - priceFilter, - dateRangeLengthFilter, - keywordFilter, intl, } = this.props; const classes = classNames(rootClassName || css.root, className); const resultsFound = ( - + ); - const noResults = ; - const loadingResults = ; + const noResults = ; + const loadingResults = ; const filtersHeading = intl.formatMessage({ id: 'SearchFiltersMobile.heading' }); const modalCloseButtonMessage = intl.formatMessage({ id: 'SearchFiltersMobile.cancel' }); @@ -249,104 +93,6 @@ class SearchFiltersMobileComponent extends Component { const filtersButtonClasses = selectedFiltersCount > 0 ? css.filtersButtonSelected : css.filtersButton; - const certificateLabel = intl.formatMessage({ - id: 'SearchFiltersMobile.certificateLabel', - }); - const initialcertificate = certificateFilter - ? this.initialValue(certificateFilter.paramName) - : null; - - const certificateFilterElement = certificateFilter ? ( - - ) : null; - - const yogaStylesLabel = intl.formatMessage({ id: 'SearchFiltersMobile.yogaStylesLabel' }); - - const initialyogaStyles = this.initialValues(yogaStylesFilter.paramName); - - const yogaStylesFilterElement = yogaStylesFilter ? ( - - ) : null; - - const initialPriceRange = this.initialPriceRangeValue(priceFilter.paramName); - - const priceFilterElement = priceFilter ? ( - - ) : null; - - const initialKeyword = this.initialValue(keywordFilter.paramName); - const keywordLabel = intl.formatMessage({ - id: 'SearchFiltersMobile.keywordLabel', - }); - const keywordFilterElement = - keywordFilter && keywordFilter.config.active ? ( - - ) : null; - - const isKeywordFilterActive = !!initialKeyword; - - const initialDates = this.initialDateRangeValue(dateRangeLengthFilter.paramName); - const initialMinDuration = this.initialValue(dateRangeLengthFilter.minDurationParamName); - - const dateRangeLengthFilterElement = - dateRangeLengthFilter && dateRangeLengthFilter.config.active ? ( - - ) : null; - - const sortBy = config.custom.sortConfig.active ? ( - - ) : null; - return (
@@ -356,11 +102,14 @@ class SearchFiltersMobileComponent extends Component {
- {sortBy} + {sortByComponent}
- +
{this.state.isFiltersOpenOnMobile ? ( -
- {dateRangeLengthFilterElement} - {priceFilterElement} - {keywordFilterElement} - {yogaStylesFilterElement} - {certificateFilterElement} -
+
{children}
) : null}
@@ -402,36 +145,27 @@ class SearchFiltersMobileComponent extends Component { SearchFiltersMobileComponent.defaultProps = { rootClassName: null, className: null, - sort: null, + sortByComponent: null, resultsCount: null, - searchingInProgress: false, + searchInProgress: false, selectedFiltersCount: 0, - filterParamNames: [], - certificateFilter: null, - yogaStylesFilter: null, - priceFilter: null, - dateRangeLengthFilter: null, }; SearchFiltersMobileComponent.propTypes = { rootClassName: string, className: string, urlQueryParams: object.isRequired, - sort: string, + sortByComponent: node, listingsAreLoaded: bool.isRequired, resultsCount: number, - searchingInProgress: bool, + searchInProgress: bool, showAsModalMaxWidth: number.isRequired, onMapIconClick: func.isRequired, onManageDisableScrolling: func.isRequired, onOpenModal: func.isRequired, onCloseModal: func.isRequired, + resetAll: func.isRequired, selectedFiltersCount: number, - filterParamNames: array, - certificateFilter: propTypes.filterConfig, - yogaStylesFilter: propTypes.filterConfig, - priceFilter: propTypes.filterConfig, - dateRangeLengthFilter: propTypes.filterConfig, // from injectIntl intl: intlShape.isRequired, diff --git a/src/components/SearchFiltersPanel/SearchFiltersPanel.js b/src/components/SearchFiltersPanel/SearchFiltersPanel.js deleted file mode 100644 index dba8cdb35..000000000 --- a/src/components/SearchFiltersPanel/SearchFiltersPanel.js +++ /dev/null @@ -1,244 +0,0 @@ -/** - * SearchFiltersPanel can be used to add extra filters to togglable panel in SearchPage. - * Using this component will need you to enable it in SearchPage and passing needed props to - * SearchFilters component (which is the default place for SearchFilters). - * - * - * An example how to render MultiSelectFilter for a filter that has it's config passed in - * the props as newFilter: - * - * initialValue for a filter can be resolved with the initialValue and initialValues - * methods. - * - * const initialNewFilterValues = this.initialValues(newFilter.paramName); - * - * const newFilterElement = newFilter ? ( - * - * ) : null; - */ - -import React, { Component } from 'react'; -import { array, func, object, shape, string } from 'prop-types'; -import classNames from 'classnames'; -import { FormattedMessage, injectIntl, intlShape } from '../../util/reactIntl'; -import { withRouter } from 'react-router-dom'; -import omit from 'lodash/omit'; - -import routeConfiguration from '../../routeConfiguration'; -import { createResourceLocatorString } from '../../util/routes'; -import { propTypes } from '../../util/types'; -import { SelectSingleFilter, SelectMultipleFilter, InlineTextButton } from '../../components'; -import css from './SearchFiltersPanel.css'; - -class SearchFiltersPanelComponent extends Component { - constructor(props) { - super(props); - this.state = { currentQueryParams: props.urlQueryParams }; - - this.applyFilters = this.applyFilters.bind(this); - this.cancelFilters = this.cancelFilters.bind(this); - this.resetAll = this.resetAll.bind(this); - this.handleSelectSingle = this.handleSelectSingle.bind(this); - this.handleSelectMultiple = this.handleSelectMultiple.bind(this); - this.initialValue = this.initialValue.bind(this); - this.initialValues = this.initialValues.bind(this); - } - - // Apply the filters by redirecting to SearchPage with new filters. - applyFilters() { - const { history, urlQueryParams, onClosePanel } = this.props; - - history.push( - createResourceLocatorString( - 'SearchPage', - routeConfiguration(), - {}, - { ...urlQueryParams, ...this.state.currentQueryParams } - ) - ); - - // Ensure that panel closes (if now changes have been made) - onClosePanel(); - } - - // Close the filters by clicking cancel, revert to the initial params - cancelFilters() { - this.setState({ currentQueryParams: {} }); - this.props.onClosePanel(); - } - - // Reset all filter query parameters - resetAll(e) { - const { urlQueryParams, history, onClosePanel, filterParamNames } = this.props; - - const queryParams = omit(urlQueryParams, filterParamNames); - history.push(createResourceLocatorString('SearchPage', routeConfiguration(), {}, queryParams)); - - // Ensure that panel closes (if now changes have been made) - onClosePanel(); - - // blur event target if event is passed - if (e && e.currentTarget) { - e.currentTarget.blur(); - } - } - - handleSelectSingle(urlParam, option) { - const urlQueryParams = this.props.urlQueryParams; - this.setState(prevState => { - const prevQueryParams = prevState.currentQueryParams; - const mergedQueryParams = { ...urlQueryParams, ...prevQueryParams }; - - // query parameters after selecting the option - // if no option is passed, clear the selection for the filter - const currentQueryParams = option - ? { ...mergedQueryParams, [urlParam]: option } - : { ...mergedQueryParams, [urlParam]: null }; - - return { currentQueryParams }; - }); - } - - handleSelectMultiple(urlParam, options) { - const urlQueryParams = this.props.urlQueryParams; - this.setState(prevState => { - const prevQueryParams = prevState.currentQueryParams; - const mergedQueryParams = { ...urlQueryParams, ...prevQueryParams }; - - // query parameters after selecting options - // if no option is passed, clear the selection from state.currentQueryParams - const currentQueryParams = - options && options.length > 0 - ? { ...mergedQueryParams, [urlParam]: options.join(',') } - : { ...mergedQueryParams, [urlParam]: null }; - - return { currentQueryParams }; - }); - } - - // resolve initial value for a single value filter - initialValue(paramName) { - 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]; - } - - // resolve initial values for a multi value filter - initialValues(paramName) { - const currentQueryParams = this.state.currentQueryParams; - const urlQueryParams = this.props.urlQueryParams; - - const splitQueryParam = queryParam => (queryParam ? queryParam.split(',') : []); - - // initialValue for a select should come either from state.currentQueryParam or urlQueryParam - const hasCurrentQueryParam = typeof currentQueryParams[paramName] !== 'undefined'; - - return hasCurrentQueryParam - ? splitQueryParam(currentQueryParams[paramName]) - : splitQueryParam(urlQueryParams[paramName]); - } - - render() { - const { rootClassName, className, intl, certificateFilter, yogaStylesFilter } = this.props; - const classes = classNames(rootClassName || css.root, className); - - const certificateLabel = intl.formatMessage({ - id: 'SearchFiltersPanel.certificateLabel', - }); - const initialcertificate = certificateFilter - ? this.initialValue(certificateFilter.paramName) - : null; - - const certificateFilterElement = certificateFilter ? ( - - ) : null; - - const yogaStylesLabel = intl.formatMessage({ id: 'SearchFiltersPanel.yogaStylesLabel' }); - - const initialyogaStyles = this.initialValues(yogaStylesFilter.paramName); - - const yogaStylesFilterElement = yogaStylesFilter ? ( - - ) : null; - - return ( -
-
- {yogaStylesFilterElement} - {certificateFilterElement} -
-
- - - - - - - - - -
-
- ); - } -} - -SearchFiltersPanelComponent.defaultProps = { - rootClassName: null, - className: null, - filterParamNames: [], - certificateFilter: null, - yogaStylesFilter: null, -}; - -SearchFiltersPanelComponent.propTypes = { - rootClassName: string, - className: string, - urlQueryParams: object.isRequired, - onClosePanel: func.isRequired, - filterParamNames: array, - certificateFilter: propTypes.filterConfig, - yogaStylesFilter: propTypes.filterConfig, - - // from injectIntl - intl: intlShape.isRequired, - - // from withRouter - history: shape({ - push: func.isRequired, - }).isRequired, -}; - -const SearchFiltersPanel = injectIntl(withRouter(SearchFiltersPanelComponent)); - -export default SearchFiltersPanel; diff --git a/src/components/SearchFilters/SearchFilters.css b/src/components/SearchFiltersPrimary/SearchFiltersPrimary.css similarity index 95% rename from src/components/SearchFilters/SearchFilters.css rename to src/components/SearchFiltersPrimary/SearchFiltersPrimary.css index 78f8f0817..9932a5421 100644 --- a/src/components/SearchFilters/SearchFilters.css +++ b/src/components/SearchFiltersPrimary/SearchFiltersPrimary.css @@ -4,6 +4,7 @@ display: flex; flex-direction: column; background-color: var(--matterColorBright); + position: relative; } .longInfo { @@ -63,6 +64,10 @@ padding: 9px 24px 0 24px; margin: 0; background-color: var(--matterColorBright); + + @media (--viewportLarge) { + padding: 9px 36px 0 36px; + } } .resultsFound { diff --git a/src/components/SearchFiltersPrimary/SearchFiltersPrimary.js b/src/components/SearchFiltersPrimary/SearchFiltersPrimary.js new file mode 100644 index 000000000..90be664e2 --- /dev/null +++ b/src/components/SearchFiltersPrimary/SearchFiltersPrimary.js @@ -0,0 +1,104 @@ +import React from 'react'; +import { bool, func, node, number, string } from 'prop-types'; +import { FormattedMessage } from '../../util/reactIntl'; +import classNames from 'classnames'; + +import css from './SearchFiltersPrimary.css'; + +const SearchFiltersPrimaryComponent = props => { + const { + rootClassName, + className, + children, + sortByComponent, + listingsAreLoaded, + resultsCount, + searchInProgress, + isSecondaryFiltersOpen, + toggleSecondaryFiltersOpen, + selectedSecondaryFiltersCount, + } = props; + + const hasNoResult = listingsAreLoaded && resultsCount === 0; + const classes = classNames(rootClassName || css.root, className); + + const toggleSecondaryFiltersOpenButtonClasses = + isSecondaryFiltersOpen || selectedSecondaryFiltersCount > 0 + ? css.searchFiltersPanelOpen + : css.searchFiltersPanelClosed; + const toggleSecondaryFiltersOpenButton = toggleSecondaryFiltersOpen ? ( + + ) : null; + + return ( +
+
+ {listingsAreLoaded ? ( +
+ + + +
+ ) : null} + {sortByComponent} +
+ +
+ {children} + {toggleSecondaryFiltersOpenButton} +
+ + {hasNoResult ? ( +
+ +
+ ) : null} + + {searchInProgress ? ( +
+ +
+ ) : null} +
+ ); +}; + +SearchFiltersPrimaryComponent.defaultProps = { + rootClassName: null, + className: null, + resultsCount: null, + searchInProgress: false, + isSecondaryFiltersOpen: false, + toggleSecondaryFiltersOpen: null, + selectedSecondaryFiltersCount: 0, + sortByComponent: null, +}; + +SearchFiltersPrimaryComponent.propTypes = { + rootClassName: string, + className: string, + listingsAreLoaded: bool.isRequired, + resultsCount: number, + searchInProgress: bool, + isSecondaryFiltersOpen: bool, + toggleSecondaryFiltersOpen: func, + selectedSecondaryFiltersCount: number, + sortByComponent: node, +}; + +const SearchFiltersPrimary = SearchFiltersPrimaryComponent; + +export default SearchFiltersPrimary; diff --git a/src/components/SearchFiltersPanel/SearchFiltersPanel.css b/src/components/SearchFiltersSecondary/SearchFiltersSecondary.css similarity index 100% rename from src/components/SearchFiltersPanel/SearchFiltersPanel.css rename to src/components/SearchFiltersSecondary/SearchFiltersSecondary.css diff --git a/src/components/SearchFiltersSecondary/SearchFiltersSecondary.js b/src/components/SearchFiltersSecondary/SearchFiltersSecondary.js new file mode 100644 index 000000000..882fa8d1c --- /dev/null +++ b/src/components/SearchFiltersSecondary/SearchFiltersSecondary.js @@ -0,0 +1,98 @@ +import React, { Component } from 'react'; +import { func, object, string } from 'prop-types'; +import classNames from 'classnames'; +import { FormattedMessage } from '../../util/reactIntl'; + +import { InlineTextButton } from '../../components'; +import css from './SearchFiltersSecondary.css'; + +class SearchFiltersSecondaryComponent extends Component { + constructor(props) { + super(props); + this.state = { currentQueryParams: props.urlQueryParams }; + + this.applyFilters = this.applyFilters.bind(this); + this.cancelFilters = this.cancelFilters.bind(this); + this.resetAll = this.resetAll.bind(this); + } + + // Apply the filters by redirecting to SearchPage with new filters. + applyFilters() { + const { applyFilters, onClosePanel } = this.props; + + if (applyFilters) { + applyFilters(); + } + + // Ensure that panel closes (if now changes have been made) + onClosePanel(); + } + + // Close the filters by clicking cancel, revert to the initial params + cancelFilters() { + const { cancelFilters } = this.props; + + if (cancelFilters) { + cancelFilters(); + } + + this.props.onClosePanel(); + } + + // Reset all filter query parameters + resetAll(e) { + const { resetAll, onClosePanel } = this.props; + + if (resetAll) { + resetAll(e); + } + + // Ensure that panel closes (if now changes have been made) + onClosePanel(); + + // blur event target if event is passed + if (e && e.currentTarget) { + e.currentTarget.blur(); + } + } + + render() { + const { rootClassName, className, children } = this.props; + const classes = classNames(rootClassName || css.root, className); + + return ( +
+
{children}
+
+ + + + + + + + + +
+
+ ); + } +} + +SearchFiltersSecondaryComponent.defaultProps = { + rootClassName: null, + className: null, +}; + +SearchFiltersSecondaryComponent.propTypes = { + rootClassName: string, + className: string, + urlQueryParams: object.isRequired, + applyFilters: func.isRequired, + resetAll: func.isRequired, + onClosePanel: func.isRequired, +}; + +const SearchFiltersSecondary = SearchFiltersSecondaryComponent; + +export default SearchFiltersSecondary; diff --git a/src/components/SelectMultipleFilter/SelectMultipleFilter.example.js b/src/components/SelectMultipleFilter/SelectMultipleFilter.example.js index 4f6e94e58..4cbc71a77 100644 --- a/src/components/SelectMultipleFilter/SelectMultipleFilter.example.js +++ b/src/components/SelectMultipleFilter/SelectMultipleFilter.example.js @@ -14,9 +14,9 @@ const options = [ { key: 'yin', label: 'yin' }, ]; -const handleSubmit = (urlParam, values, history) => { +const handleSubmit = (values, history) => { console.log('Submitting values', values); - const queryParams = values ? `?${stringify({ [urlParam]: values.join(',') })}` : ''; + const queryParams = values ? `?${stringify(values)}` : ''; history.push(`${window.location.pathname}${queryParams}`); }; @@ -25,15 +25,15 @@ const YogaStylesFilterPopup = withRouter(props => { const params = parse(location.search); const yogaStyles = params[URL_PARAM]; - const initialValues = !!yogaStyles ? yogaStyles.split(',') : []; + const initialValues = { [URL_PARAM]: !!yogaStyles ? yogaStyles : null }; return ( handleSubmit(urlParam, values, history)} + onSubmit={values => handleSubmit(values, history)} showAsPopup={true} liveEdit={false} options={options} @@ -54,16 +54,16 @@ const YogaStylesFilterPlain = withRouter(props => { const params = parse(location.search); const yogaStyles = params[URL_PARAM]; - const initialValues = !!yogaStyles ? yogaStyles.split(',') : []; + const initialValues = { [URL_PARAM]: !!yogaStyles ? yogaStyles : null }; return ( { - handleSubmit(urlParam, values, history); + onSubmit={values => { + handleSubmit(values, history); }} showAsPopup={false} liveEdit={true} diff --git a/src/components/SelectMultipleFilter/SelectMultipleFilter.js b/src/components/SelectMultipleFilter/SelectMultipleFilter.js index 09c868d28..3f1832eb2 100644 --- a/src/components/SelectMultipleFilter/SelectMultipleFilter.js +++ b/src/components/SelectMultipleFilter/SelectMultipleFilter.js @@ -1,7 +1,8 @@ import React, { Component } from 'react'; -import { array, arrayOf, func, number, string } from 'prop-types'; +import { array, arrayOf, func, node, number, object, string } from 'prop-types'; import classNames from 'classnames'; import { injectIntl, intlShape } from '../../util/reactIntl'; +import { parseSelectFilterOptions } from '../../util/search'; import { FieldCheckbox } from '../../components'; import { FilterPopup, FilterPlain } from '../../components'; @@ -28,6 +29,18 @@ const GroupOfFieldCheckboxes = props => { ); }; +const getQueryParamName = queryParamNames => { + return Array.isArray(queryParamNames) ? queryParamNames[0] : queryParamNames; +}; + +// Format URI component's query param: { pub_key: 'has_all:a,b,c' } +const format = (selectedOptions, queryParamName, searchMode) => { + const hasOptionsSelected = selectedOptions && selectedOptions.length > 0; + const mode = searchMode ? `${searchMode}:` : ''; + const value = hasOptionsSelected ? `${mode}${selectedOptions.join(',')}` : null; + return { [queryParamName]: value }; +}; + class SelectMultipleFilter extends Component { constructor(props) { super(props); @@ -72,7 +85,8 @@ class SelectMultipleFilter extends Component { initialValues, contentPlacementOffset, onSubmit, - urlParam, + queryParamNames, + searchMode, intl, showAsPopup, ...rest @@ -80,18 +94,24 @@ class SelectMultipleFilter extends Component { const classes = classNames(rootClassName || css.root, className); - const hasInitialValues = initialValues.length > 0; + const queryParamName = getQueryParamName(queryParamNames); + const hasInitialValues = !!initialValues && !!initialValues[queryParamName]; + // Parse options from param strings like "has_all:a,b,c" or "a,b,c" + const selectedOptions = hasInitialValues + ? parseSelectFilterOptions(initialValues[queryParamName]) + : []; + const labelForPopup = hasInitialValues ? intl.formatMessage( { id: 'SelectMultipleFilter.labelSelected' }, - { labelText: label, count: initialValues.length } + { labelText: label, count: selectedOptions.length } ) : label; const labelForPlain = hasInitialValues ? intl.formatMessage( { id: 'SelectMultipleFilterPlainForm.labelSelected' }, - { labelText: label, count: initialValues.length } + { labelText: label, count: selectedOptions.length } ) : label; @@ -99,11 +119,11 @@ class SelectMultipleFilter extends Component { // pass the initial values with the name key so that // they can be passed to the correct field - const namedInitialValues = { [name]: initialValues }; + const namedInitialValues = { [name]: selectedOptions }; - const handleSubmit = (urlParam, values) => { + const handleSubmit = values => { const usedValue = values ? values[name] : values; - onSubmit(urlParam, usedValue); + onSubmit(format(usedValue, queryParamName, searchMode)); }; return showAsPopup ? ( @@ -119,7 +139,6 @@ class SelectMultipleFilter extends Component { contentPlacementOffset={contentPlacementOffset} onSubmit={handleSubmit} initialValues={namedInitialValues} - urlParam={urlParam} keepDirtyOnReinitialize {...rest} > @@ -141,7 +160,6 @@ class SelectMultipleFilter extends Component { contentPlacementOffset={contentStyle} onSubmit={handleSubmit} initialValues={namedInitialValues} - urlParam={urlParam} {...rest} > { + return Array.isArray(queryParamNames) ? queryParamNames[0] : queryParamNames; +}; + class SelectSingleFilterPlain extends Component { constructor(props) { super(props); @@ -14,8 +18,9 @@ class SelectSingleFilterPlain extends Component { } selectOption(option, e) { - const { urlParam, onSelect } = this.props; - onSelect(urlParam, option); + const { queryParamNames, onSelect } = this.props; + const queryParamName = getQueryParamName(queryParamNames); + onSelect({ [queryParamName]: option }); // blur event target if event is passed if (e && e.currentTarget) { @@ -33,11 +38,15 @@ class SelectSingleFilterPlain extends Component { className, label, options, - initialValue, + queryParamNames, + initialValues, twoColumns, useBullets, } = this.props; + const queryParamName = getQueryParamName(queryParamNames); + const initialValue = + initialValues && initialValues[queryParamName] ? initialValues[queryParamName] : null; const labelClass = initialValue ? css.filterLabelSelected : css.filterLabel; const hasBullets = useBullets || twoColumns; @@ -95,7 +104,7 @@ class SelectSingleFilterPlain extends Component { SelectSingleFilterPlain.defaultProps = { rootClassName: null, className: null, - initialValue: null, + initialValues: null, twoColumns: false, useBullets: false, }; @@ -103,8 +112,8 @@ SelectSingleFilterPlain.defaultProps = { SelectSingleFilterPlain.propTypes = { rootClassName: string, className: string, - urlParam: string.isRequired, - label: string.isRequired, + queryParamNames: arrayOf(string).isRequired, + label: node.isRequired, onSelect: func.isRequired, options: arrayOf( @@ -113,7 +122,7 @@ SelectSingleFilterPlain.propTypes = { label: string.isRequired, }) ).isRequired, - initialValue: string, + initialValues: object, twoColumns: bool, useBullets: bool, }; diff --git a/src/components/SelectSingleFilter/SelectSingleFilterPopup.js b/src/components/SelectSingleFilter/SelectSingleFilterPopup.js index d4062a1f6..d042a99a3 100644 --- a/src/components/SelectSingleFilter/SelectSingleFilterPopup.js +++ b/src/components/SelectSingleFilter/SelectSingleFilterPopup.js @@ -1,5 +1,5 @@ import React, { Component } from 'react'; -import { string, func, arrayOf, shape, number } from 'prop-types'; +import { arrayOf, func, node, number, object, shape, string } from 'prop-types'; import { FormattedMessage } from '../../util/reactIntl'; import classNames from 'classnames'; @@ -11,6 +11,10 @@ const optionLabel = (options, key) => { return option ? option.label : key; }; +const getQueryParamName = queryParamNames => { + return Array.isArray(queryParamNames) ? queryParamNames[0] : queryParamNames; +}; + class SelectSingleFilterPopup extends Component { constructor(props) { super(props); @@ -24,22 +28,26 @@ class SelectSingleFilterPopup extends Component { this.setState({ isOpen: isOpen }); } - selectOption(urlParam, option) { + selectOption(queryParamName, option) { this.setState({ isOpen: false }); - this.props.onSelect(urlParam, option); + this.props.onSelect({ [queryParamName]: option }); } render() { const { rootClassName, className, - urlParam, label, options, - initialValue, + queryParamNames, + initialValues, contentPlacementOffset, } = this.props; + const queryParamName = getQueryParamName(queryParamNames); + const initialValue = + initialValues && initialValues[queryParamNames] ? initialValues[queryParamNames] : null; + // resolve menu label text and class const menuLabel = initialValue ? optionLabel(options, initialValue) : label; const menuLabelClass = initialValue ? css.menuLabelSelected : css.menuLabel; @@ -66,7 +74,7 @@ class SelectSingleFilterPopup extends Component { @@ -88,15 +99,15 @@ class SelectSingleFilterPopup extends Component { SelectSingleFilterPopup.defaultProps = { rootClassName: null, className: null, - initialValue: null, + initialValues: null, contentPlacementOffset: 0, }; SelectSingleFilterPopup.propTypes = { rootClassName: string, className: string, - urlParam: string.isRequired, - label: string.isRequired, + queryParamNames: arrayOf(string).isRequired, + label: node.isRequired, onSelect: func.isRequired, options: arrayOf( shape({ @@ -104,7 +115,7 @@ SelectSingleFilterPopup.propTypes = { label: string.isRequired, }) ).isRequired, - initialValue: string, + initialValues: object, contentPlacementOffset: number, }; diff --git a/src/components/SortBy/SortBy.js b/src/components/SortBy/SortBy.js index 254d1ce72..962e87fae 100644 --- a/src/components/SortBy/SortBy.js +++ b/src/components/SortBy/SortBy.js @@ -8,23 +8,24 @@ import SortByPlain from './SortByPlain'; import SortByPopup from './SortByPopup'; const SortBy = props => { - const { sort, showAsPopup, isKeywordFilterActive, intl, ...rest } = props; + const { sort, showAsPopup, isConflictingFilterActive, intl, ...rest } = props; - const { relevanceKey } = config.custom.sortConfig; + const { relevanceKey, queryParamName } = config.custom.sortConfig; const options = config.custom.sortConfig.options.map(option => { const isRelevance = option.key === relevanceKey; return { ...option, - disabled: (isRelevance && !isKeywordFilterActive) || (!isRelevance && isKeywordFilterActive), + disabled: + (isRelevance && !isConflictingFilterActive) || (!isRelevance && isConflictingFilterActive), }; }); const defaultValue = 'createdAt'; const componentProps = { - urlParam: 'sort', + urlParam: queryParamName, label: intl.formatMessage({ id: 'SortBy.heading' }), options, - initialValue: isKeywordFilterActive ? relevanceKey : sort || defaultValue, + initialValue: isConflictingFilterActive ? relevanceKey : sort || defaultValue, ...rest, }; return showAsPopup ? : ; @@ -38,7 +39,7 @@ SortBy.defaultProps = { SortBy.propTypes = { sort: string, showAsPopup: bool, - isKeywordFilterActive: bool.isRequired, + isConflictingFilterActive: bool.isRequired, intl: intlShape.isRequired, }; diff --git a/src/components/index.js b/src/components/index.js index 1b9ede58f..88dc91fae 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -137,9 +137,9 @@ export { default as Page } from './Page/Page'; export { default as PriceFilter } from './PriceFilter/PriceFilter'; export { default as Reviews } from './Reviews/Reviews'; export { default as SavedCardDetails } from './SavedCardDetails/SavedCardDetails'; -export { default as SearchFilters } from './SearchFilters/SearchFilters'; export { default as SearchFiltersMobile } from './SearchFiltersMobile/SearchFiltersMobile'; -export { default as SearchFiltersPanel } from './SearchFiltersPanel/SearchFiltersPanel'; +export { default as SearchFiltersPrimary } from './SearchFiltersPrimary/SearchFiltersPrimary'; +export { default as SearchFiltersSecondary } from './SearchFiltersSecondary/SearchFiltersSecondary'; export { default as SearchMap } from './SearchMap/SearchMap'; export { default as SearchMapGroupLabel } from './SearchMapGroupLabel/SearchMapGroupLabel'; export { default as SearchMapInfoCard } from './SearchMapInfoCard/SearchMapInfoCard'; diff --git a/src/containers/ListingPage/ListingPage.js b/src/containers/ListingPage/ListingPage.js index 4ae7d4cb8..e4e90bd01 100644 --- a/src/containers/ListingPage/ListingPage.js +++ b/src/containers/ListingPage/ListingPage.js @@ -7,6 +7,7 @@ import { connect } from 'react-redux'; import { withRouter } from 'react-router-dom'; import config from '../../config'; import routeConfiguration from '../../routeConfiguration'; +import { findOptionsForSelectFilter } from '../../util/search'; import { LISTING_STATE_PENDING_APPROVAL, LISTING_STATE_CLOSED, propTypes } from '../../util/types'; import { types as sdkTypes } from '../../util/sdkLoader'; import { @@ -191,8 +192,7 @@ export class ListingPageComponent extends Component { sendEnquiryInProgress, sendEnquiryError, monthlyTimeSlots, - certificateConfig, - yogaStylesConfig, + filterConfig, } = this.props; const listingId = new UUID(rawParams.id); @@ -371,6 +371,10 @@ export class ListingPageComponent extends Component { {authorDisplayName} ); + + const yogaStylesOptions = findOptionsForSelectFilter('yogaStyles', filterConfig); + const certificateOptions = findOptionsForSelectFilter('certificate', filterConfig); + return ( - + { diff --git a/src/containers/ListingPage/ListingPage.test.js b/src/containers/ListingPage/ListingPage.test.js index 801a5b1cb..9b84deff9 100644 --- a/src/containers/ListingPage/ListingPage.test.js +++ b/src/containers/ListingPage/ListingPage.test.js @@ -30,12 +30,41 @@ import ActionBarMaybe from './ActionBarMaybe'; const { UUID } = sdkTypes; const noop = () => null; -const certificateConfig = [{ key: 'cat1', label: 'Cat 1' }, { key: 'cat2', label: 'Cat 2' }]; - -const yogaStylesConfig = [ - { key: 'feat1', label: 'Feat 1' }, - { key: 'feat2', label: 'Feat 2' }, - { key: 'feat3', label: 'Feat 3' }, +const filterConfig = [ + { + id: 'certificate', + label: 'Certificate', + type: 'SelectSingleFilter', + group: 'secondary', + queryParamName: 'pub_certificate', + config: { + options: [{ key: 'cat1', label: 'Cat 1' }, { key: 'cat2', label: 'Cat 2' }], + }, + }, + { + id: 'yogaStyles', + label: 'yogaStyles', + type: 'SelectMultipleFilter', + group: 'secondary', + queryParamName: 'pub_yogaStyles', + config: { + mode: 'has_all', + options: [ + { + key: 'feat1', + label: 'Feat 1', + }, + { + key: 'feat2', + label: 'Feat 2', + }, + { + key: 'feat3', + label: 'Feat 3', + }, + ], + }, + }, ]; describe('ListingPage', () => { @@ -77,8 +106,7 @@ describe('ListingPage', () => { sendEnquiryInProgress: false, onSendEnquiry: noop, onFetchTimeSlots: noop, - certificateConfig, - yogaStylesConfig, + filterConfig, }; const tree = renderShallow(); diff --git a/src/containers/ListingPage/SectionHeading.js b/src/containers/ListingPage/SectionHeading.js index 7b70aa97a..914f5848d 100644 --- a/src/containers/ListingPage/SectionHeading.js +++ b/src/containers/ListingPage/SectionHeading.js @@ -4,20 +4,20 @@ import { InlineTextButton } from '../../components'; import css from './ListingPage.css'; -const getCertificateInfo = (certificateConfig, key) => { - return certificateConfig.find(c => c.key === key); +const getCertificateInfo = (certificateOptions, key) => { + return certificateOptions.find(c => c.key === key); }; const SectionHeading = props => { const { richTitle, listingCertificate, - certificateConfig, + certificateOptions, showContactUser, onContactUser, } = props; - const certificate = getCertificateInfo(certificateConfig, listingCertificate); + const certificate = getCertificateInfo(certificateOptions, listingCertificate); const showCertificate = certificate && !certificate.hideFromListingInfo; return (
diff --git a/src/containers/ListingPage/__snapshots__/ListingPage.test.js.snap b/src/containers/ListingPage/__snapshots__/ListingPage.test.js.snap index 3c454f47d..0d0527f80 100644 --- a/src/containers/ListingPage/__snapshots__/ListingPage.test.js.snap +++ b/src/containers/ListingPage/__snapshots__/ListingPage.test.js.snap @@ -120,7 +120,7 @@ exports[`ListingPage matches snapshot 1`] = ` />
{ + const { + idPrefix, + filterConfig, + urlQueryParams, + initialValues, + getHandleChangedValueFn, + ...rest + } = props; + const { id, type, queryParamNames, label, config } = filterConfig; + const { liveEdit, showAsPopup } = rest; + + const useHistoryPush = liveEdit || showAsPopup; + const prefix = idPrefix || 'SearchPage'; + const componentId = `${prefix}.${id.toLowerCase()}`; + const name = id.replace(/\s+/g, '-').toLowerCase(); + + switch (type) { + case 'SelectSingleFilter': { + return ( + + ); + } + case 'SelectMultipleFilter': { + return ( + + ); + } + case 'BookingDateRangeFilter': { + return ( + + ); + } + case 'BookingDateRangeLengthFilter': { + return ( + + ); + } + case 'PriceFilter': { + return ( + + ); + } + case 'KeywordFilter': + return ( + + ); + default: + return null; + } +}; + +export default FilterComponent; diff --git a/src/containers/SearchPage/MainPanel.js b/src/containers/SearchPage/MainPanel.js index 53d2127a6..1a7f957c9 100644 --- a/src/containers/SearchPage/MainPanel.js +++ b/src/containers/SearchPage/MainPanel.js @@ -1,23 +1,145 @@ import React, { Component } from 'react'; -import { array, bool, func, number, object, objectOf, string } from 'prop-types'; -import { FormattedMessage } from '../../util/reactIntl'; +import { array, bool, func, number, object, shape, string } from 'prop-types'; import classNames from 'classnames'; -import merge from 'lodash/merge'; +import omit from 'lodash/omit'; +import config from '../../config'; +import routeConfiguration from '../../routeConfiguration'; +import { FormattedMessage } from '../../util/reactIntl'; +import { createResourceLocatorString } from '../../util/routes'; +import { isAnyFilterActive } from '../../util/search'; import { propTypes } from '../../util/types'; import { SearchResultsPanel, - SearchFilters, SearchFiltersMobile, - SearchFiltersPanel, + SearchFiltersPrimary, + SearchFiltersSecondary, + SortBy, } from '../../components'; + +import FilterComponent from './FilterComponent'; import { validFilterParams } from './SearchPage.helpers'; import css from './SearchPage.css'; +// Primary filters have their content in dropdown-popup. +// With this offset we move the dropdown to the left a few pixels on desktop layout. +const FILTER_DROPDOWN_OFFSET = -14; + +const cleanSearchFromConflictingParams = (searchParams, sortConfig, filterConfig) => { + // Single out filters that should disable SortBy when an active + // keyword search sorts the listings according to relevance. + // In those cases, sort parameter should be removed. + const sortingFiltersActive = isAnyFilterActive( + sortConfig.conflictingFilters, + searchParams, + filterConfig + ); + return sortingFiltersActive + ? { ...searchParams, [sortConfig.queryParamName]: null } + : searchParams; +}; + +/** + * MainPanel contains search results and filters. + * There are 3 presentational container-components that show filters: + * SearchfiltersMobile, SearchFiltersPrimary, and SearchFiltersSecondary. + * The last 2 are for desktop layout. + */ class MainPanel extends Component { constructor(props) { super(props); - this.state = { isSearchFiltersPanelOpen: false }; + this.state = { isSecondaryFiltersOpen: false, currentQueryParams: props.urlQueryParams }; + + this.applyFilters = this.applyFilters.bind(this); + this.cancelFilters = this.cancelFilters.bind(this); + this.resetAll = this.resetAll.bind(this); + + this.initialValues = this.initialValues.bind(this); + this.getHandleChangedValueFn = this.getHandleChangedValueFn.bind(this); + + // SortBy + this.handleSortBy = this.handleSortBy.bind(this); + } + + // Apply the filters by redirecting to SearchPage with new filters. + applyFilters() { + const { history, urlQueryParams, sortConfig, filterConfig } = this.props; + const searchParams = { ...urlQueryParams, ...this.state.currentQueryParams }; + const search = cleanSearchFromConflictingParams(searchParams, sortConfig, filterConfig); + + history.push(createResourceLocatorString('SearchPage', routeConfiguration(), {}, search)); + } + + // Close the filters by clicking cancel, revert to the initial params + cancelFilters() { + this.setState({ currentQueryParams: {} }); + } + + // Reset all filter query parameters + resetAll(e) { + const { urlQueryParams, history, filterConfig } = this.props; + const filterQueryParamNames = filterConfig.map(f => f.queryParamNames); + + // Reset state + this.setState({ currentQueryParams: {} }); + + // Reset routing params + const queryParams = omit(urlQueryParams, filterQueryParamNames); + history.push(createResourceLocatorString('SearchPage', routeConfiguration(), {}, queryParams)); + } + + initialValues(queryParamNames) { + // Query parameters that are visible in the URL + const urlQueryParams = this.props.urlQueryParams; + // Query parameters that are in state (user might have not yet clicked "Apply") + const currentQueryParams = this.state.currentQueryParams; + + // Get initial value for a given parameter from state if its there. + const getInitialValue = paramName => { + const currentQueryParam = currentQueryParams[paramName]; + const hasQueryParamInState = typeof currentQueryParam !== 'undefined'; + return hasQueryParamInState ? currentQueryParam : urlQueryParams[paramName]; + }; + + // Return all the initial values related to given queryParamNames + // InitialValues for "amenities" filter could be + // { amenities: "has_any:towel,jacuzzi" } + const isArray = Array.isArray(queryParamNames); + return isArray + ? queryParamNames.reduce((acc, paramName) => { + return { ...acc, [paramName]: getInitialValue(paramName) }; + }, {}) + : {}; + } + + getHandleChangedValueFn(useHistoryPush) { + const { urlQueryParams, history, sortConfig, filterConfig } = this.props; + + return updatedURLParams => { + const updater = prevState => { + const mergedQueryParams = { ...urlQueryParams, ...prevState.currentQueryParams }; + return { currentQueryParams: { ...mergedQueryParams, ...updatedURLParams } }; + }; + + const callback = () => { + if (useHistoryPush) { + const searchParams = this.state.currentQueryParams; + const search = cleanSearchFromConflictingParams(searchParams, sortConfig, filterConfig); + history.push(createResourceLocatorString('SearchPage', routeConfiguration(), {}, search)); + } + }; + + this.setState(updater, callback); + }; + } + + handleSortBy(urlParam, values) { + const { history, urlQueryParams } = this.props; + const queryParams = values + ? { ...urlQueryParams, [urlParam]: values } + : omit(urlQueryParams, urlParam); + + history.push(createResourceLocatorString('SearchPage', routeConfiguration(), {}, queryParams)); } render() { @@ -25,7 +147,6 @@ class MainPanel extends Component { className, rootClassName, urlQueryParams, - sort, listings, searchInProgress, searchListingsError, @@ -38,28 +159,32 @@ class MainPanel extends Component { pagination, searchParamsForPagination, showAsModalMaxWidth, - primaryFilters, - secondaryFilters, + filterConfig, + sortConfig, } = this.props; - const isSearchFiltersPanelOpen = !!secondaryFilters && this.state.isSearchFiltersPanelOpen; + const primaryFilters = filterConfig.filter(f => f.group === 'primary'); + const secondaryFilters = filterConfig.filter(f => f.group !== 'primary'); + const hasSecondaryFilters = !!(secondaryFilters && secondaryFilters.length > 0); - const filters = merge({}, primaryFilters, secondaryFilters); - const selectedFilters = validFilterParams(urlQueryParams, filters); + // Selected aka active filters + const selectedFilters = validFilterParams(urlQueryParams, filterConfig); const selectedFiltersCount = Object.keys(selectedFilters).length; - const selectedSecondaryFilters = secondaryFilters + // Selected aka active secondary filters + const selectedSecondaryFilters = hasSecondaryFilters ? validFilterParams(urlQueryParams, secondaryFilters) : {}; - const searchFiltersPanelSelectedCount = Object.keys(selectedSecondaryFilters).length; + const selectedSecondaryFiltersCount = Object.keys(selectedSecondaryFilters).length; - const searchFiltersPanelProps = !!secondaryFilters + const isSecondaryFiltersOpen = !!hasSecondaryFilters && this.state.isSecondaryFiltersOpen; + const propsForSecondaryFiltersToggle = hasSecondaryFilters ? { - isSearchFiltersPanelOpen: this.state.isSearchFiltersPanelOpen, - toggleSearchFiltersPanel: isOpen => { - this.setState({ isSearchFiltersPanelOpen: isOpen }); + isSecondaryFiltersOpen: this.state.isSecondaryFiltersOpen, + toggleSecondaryFiltersOpen: isOpen => { + this.setState({ isSecondaryFiltersOpen: isOpen }); }, - searchFiltersPanelSelectedCount, + selectedSecondaryFiltersCount, } : {}; @@ -76,31 +201,64 @@ class MainPanel extends Component { const listingsAreLoaded = !searchInProgress && searchParamsAreInSync; - const classes = classNames(rootClassName || css.searchResultContainer, className); + const sortBy = mode => { + const conflictingFilterActive = isAnyFilterActive( + sortConfig.conflictingFilters, + urlQueryParams, + filterConfig + ); - const filterParamNames = Object.values(filters).map(f => f.paramName); - const secondaryFilterParamNames = secondaryFilters - ? Object.values(secondaryFilters).map(f => f.paramName) - : []; + const mobileClassesMaybe = + mode === 'mobile' + ? { + rootClassName: css.sortBy, + menuLabelRootClassName: css.sortByMenuLabel, + } + : {}; + return sortConfig.active ? ( + + ) : null; + }; + + const classes = classNames(rootClassName || css.searchResultContainer, className); return (
- + {...propsForSecondaryFiltersToggle} + > + {primaryFilters.map(config => { + return ( + + ); + })} + - {isSearchFiltersPanelOpen ? ( + > + {filterConfig.map(config => { + return ( + + ); + })} + + {isSecondaryFiltersOpen ? (
- this.setState({ isSearchFiltersPanelOpen: false })} - filterParamNames={secondaryFilterParamNames} - {...secondaryFilters} - /> + applyFilters={this.applyFilters} + cancelFilters={this.cancelFilters} + resetAll={this.resetAll} + onClosePanel={() => this.setState({ isSecondaryFiltersOpen: false })} + > + {secondaryFilters.map(config => { + return ( + + ); + })} +
) : (
(dispatch, getState, sdk) => { ? { minDuration: minDurationParam } : {}; - const timeZone = config.custom.dateRangeLengthFilterConfig.searchTimeZone; + // Find configs for 'dates-length' filter + // (type: BookingDateRangeLengthFilter) + const filterConfigs = config.custom.filters; + const idOfBookingDateRangeLengthFilter = 'dates-length'; + const dateLengthFilterConfig = filterConfigs.find( + f => f.id === idOfBookingDateRangeLengthFilter + ); + // Extract time zone + const timeZone = dateLengthFilterConfig.config.searchTimeZone; return hasDateValues ? { diff --git a/src/containers/SearchPage/SearchPage.helpers.js b/src/containers/SearchPage/SearchPage.helpers.js index 6a8a102c7..1a2336b9d 100644 --- a/src/containers/SearchPage/SearchPage.helpers.js +++ b/src/containers/SearchPage/SearchPage.helpers.js @@ -1,44 +1,52 @@ import intersection from 'lodash/intersection'; import config from '../../config'; import { createResourceLocatorString } from '../../util/routes'; +import { parseSelectFilterOptions } from '../../util/search'; import { createSlug } from '../../util/urlHelpers'; import routeConfiguration from '../../routeConfiguration'; +const flatten = (acc, val) => acc.concat(val); + /** * Validates a filter search param agains a filters configuration. * * All invalid param names and values are dropped * - * @param {String} paramName Search parameter name + * @param {String} queryParamName Search parameter name * @param {Object} paramValue Search parameter value * @param {Object} filters Filters configuration */ -export const validURLParamForExtendedData = (paramName, paramValueRaw, filters) => { - const filtersArray = Object.values(filters); - // resolve configuration for this filter - const filterConfig = filtersArray.find(f => f.paramName === paramName); +export const validURLParamForExtendedData = (queryParamName, paramValueRaw, filters) => { + // Resolve configuration for this filter + const filterConfig = filters.find(f => { + const isArray = Array.isArray(f.queryParamNames); + return isArray + ? f.queryParamNames.includes(queryParamName) + : f.queryParamNames === queryParamName; + }); const paramValue = paramValueRaw.toString(); - const valueArray = paramValue ? paramValue.split(',') : []; - if (filterConfig && valueArray.length > 0) { - const { min, max, active } = filterConfig.config || {}; - - if (filterConfig.options) { - // Single and multiselect filters - const allowedValues = filterConfig.options.map(o => o.key); + if (filterConfig) { + const { min, max } = filterConfig.config || {}; + if (['SelectSingleFilter', 'SelectMultipleFilter'].includes(filterConfig.type)) { + // Pick valid select options only + const allowedValues = filterConfig.config.options.map(o => o.key); + const valueArray = parseSelectFilterOptions(paramValue); const validValues = intersection(valueArray, allowedValues).join(','); - return validValues.length > 0 ? { [paramName]: validValues } : {}; - } else if (filterConfig.config && min != null && max != null) { - // Price filter + + return validValues.length > 0 ? { [queryParamName]: validValues } : {}; + } else if (filterConfig.type === 'PriceFilter') { + // Restrict price range to correct min & max + const valueArray = paramValue ? paramValue.split(',') : []; const validValues = valueArray.map(v => { return v < min ? min : v > max ? max : v; }); - return validValues.length === 2 ? { [paramName]: validValues.join(',') } : {}; - } else if (filterConfig.config && active) { - // Generic filter - return paramValue.length > 0 ? { [paramName]: paramValue } : {}; + return validValues.length === 2 ? { [queryParamName]: validValues.join(',') } : {}; + } else if (filterConfig) { + // Generic filter - remove empty params + return paramValue.length > 0 ? { [queryParamName]: paramValue } : {}; } } return {}; @@ -53,12 +61,11 @@ export const validURLParamForExtendedData = (paramName, paramValueRaw, filters) * @param {Object} filters Filters configuration */ export const validFilterParams = (params, filters) => { - const filterParamNames = Object.values(filters).map(f => f.paramName); + const filterParamNames = filters.map(f => f.queryParamNames).reduce(flatten, []); const paramEntries = Object.entries(params); return paramEntries.reduce((validParams, entry) => { - const paramName = entry[0]; - const paramValue = entry[1]; + const [paramName, paramValue] = entry; return filterParamNames.includes(paramName) ? { @@ -78,12 +85,11 @@ export const validFilterParams = (params, filters) => { * @param {Object} filters Filters configuration */ export const validURLParamsForExtendedData = (params, filters) => { - const filterParamNames = Object.values(filters).map(f => f.paramName); + const filterParamNames = filters.map(f => f.queryParamNames).reduce(flatten, []); const paramEntries = Object.entries(params); return paramEntries.reduce((validParams, entry) => { - const paramName = entry[0]; - const paramValue = entry[1]; + const [paramName, paramValue] = entry; return filterParamNames.includes(paramName) ? { @@ -96,16 +102,19 @@ export const validURLParamsForExtendedData = (params, filters) => { // extract search parameters, including a custom URL params // which are validated by mapping the values to marketplace custom config. -export const pickSearchParamsOnly = (params, filters) => { +export const pickSearchParamsOnly = (params, filters, sortConfig) => { const { address, origin, bounds, ...rest } = params || {}; const boundsMaybe = bounds ? { bounds } : {}; const originMaybe = config.sortSearchByDistance && origin ? { origin } : {}; const filterParams = validFilterParams(rest, filters); + const sort = rest[sortConfig.queryParamName]; + const sortMaybe = sort ? { sort } : {}; return { ...boundsMaybe, ...originMaybe, ...filterParams, + ...sortMaybe, }; }; diff --git a/src/containers/SearchPage/SearchPage.helpers.test.js b/src/containers/SearchPage/SearchPage.helpers.test.js index bf2901d47..d9e0aa0f0 100644 --- a/src/containers/SearchPage/SearchPage.helpers.test.js +++ b/src/containers/SearchPage/SearchPage.helpers.test.js @@ -10,84 +10,136 @@ const urlParams = { pub_yogaStyles: 'vinyasa,yin', }; -const filters = { - certificateFilter: { - paramName: 'pub_certificate', - options: [{ key: '200h' }, { key: '500h' }], +const filters = [ + { + id: 'certificate', + label: 'Certificate', + type: 'SelectSingleFilter', + group: 'secondary', + queryParamNames: ['pub_certificate'], + config: { + options: [{ key: '200h' }, { key: '500h' }], + }, }, - yogaStylesFilter: { - paramName: 'pub_yogaStyles', - options: [{ key: 'vinyasa' }, { key: 'yin' }], + { + id: 'test', + label: 'Test', + type: 'SelectSingleFilter', + group: 'secondary', + queryParamNames: ['pub_param1', 'pub_param1'], + config: { + options: [{ key: 'smoke', label: 'Smoke' }, { key: 'wooden', label: 'Wood' }], + }, }, + { + id: 'yogaStyles', + label: 'Yoga styles', + type: 'SelectMultipleFilter', + group: 'secondary', + queryParamNames: ['pub_yogaStyles'], + config: { + mode: 'has_all', + options: [{ key: 'vinyasa' }, { key: 'yin' }], + }, + }, +]; + +const sortConfig = { + active: true, + queryParamName: 'sort', + relevanceKey: 'relevance', + conflictingFilters: ['keyword'], + options: [ + { key: 'createdAt', label: 'Newest' }, + { key: '-createdAt', label: 'Oldest' }, + { key: '-price', label: 'Lowest price' }, + { key: 'price', label: 'Highest price' }, + { key: 'relevance', label: 'Relevance', longLabel: 'Relevance (Keyword search)' }, + ], }; describe('SearchPage.helpers', () => { describe('validURLParamForExtendedData', () => { it('returns a valid parameter', () => { - const validParam = validURLParamForExtendedData('pub_certificate', '200h', filters); + const validParam = validURLParamForExtendedData( + 'pub_certificate', + '200h', + filters, + sortConfig + ); expect(validParam).toEqual({ pub_certificate: '200h' }); }); it('takes empty params', () => { - const validParam = validURLParamForExtendedData('pub_certificate', '', filters); + const validParam = validURLParamForExtendedData('pub_certificate', '', filters, sortConfig); expect(validParam).toEqual({}); }); it('drops an invalid param value', () => { - const validParam = validURLParamForExtendedData('pub_certificate', 'invalid', filters); + const validParam = validURLParamForExtendedData( + 'pub_certificate', + 'invalid', + filters, + sortConfig + ); expect(validParam).toEqual({}); }); it('drops a param with invalid name', () => { - const validParam = validURLParamForExtendedData('pub_invalid', 'vinyasa', filters); + const validParam = validURLParamForExtendedData( + 'pub_invalid', + 'vinyasa', + filters, + sortConfig + ); expect(validParam).toEqual({}); }); }); describe('validFilterParams', () => { it('returns valid parameters', () => { - const validParams = validFilterParams(urlParams, filters); + const validParams = validFilterParams(urlParams, filters, sortConfig); expect(validParams).toEqual(urlParams); }); it('takes empty params', () => { - const validParams = validFilterParams({}, filters); + const validParams = validFilterParams({}, filters, sortConfig); expect(validParams).toEqual({}); }); it('drops an invalid filter param value', () => { const params = { pub_certificate: '200h', pub_yogaStyles: 'invalid1,invalid2' }; - const validParams = validFilterParams(params, filters); + const validParams = validFilterParams(params, filters, sortConfig); expect(validParams).toEqual({ pub_certificate: '200h' }); }); it('drops non-filter params', () => { const params = { pub_certificate: '200h', other_param: 'somevalue' }; - const validParams = validFilterParams(params, filters); + const validParams = validFilterParams(params, filters, sortConfig); expect(validParams).toEqual({ pub_certificate: '200h' }); }); }); describe('validURLParamsForExtendedData', () => { it('returns valid parameters', () => { - const validParams = validURLParamsForExtendedData(urlParams, filters); + const validParams = validURLParamsForExtendedData(urlParams, filters, sortConfig); expect(validParams).toEqual(urlParams); }); it('takes empty params', () => { - const validParams = validURLParamsForExtendedData({}, filters); + const validParams = validURLParamsForExtendedData({}, filters, sortConfig); expect(validParams).toEqual({}); }); it('drops an invalid filter param value', () => { const params = { pub_certificate: '200h', pub_yogaStyles: 'invalid1,invalid2' }; - const validParams = validURLParamsForExtendedData(params, filters); + const validParams = validURLParamsForExtendedData(params, filters, sortConfig); expect(validParams).toEqual({ pub_certificate: '200h' }); }); it('returns non-filter params', () => { const params = { pub_certificate: '200h', other_param: 'somevalue' }; - const validParams = validURLParamsForExtendedData(params, filters); + const validParams = validURLParamsForExtendedData(params, filters, sortConfig); expect(validParams).toEqual(params); }); }); @@ -99,25 +151,31 @@ describe('SearchPage.helpers', () => { origin: 'origin value', bounds: 'bounds value', }; - const validParams = pickSearchParamsOnly(params, filters); + const validParams = pickSearchParamsOnly(params, filters, sortConfig); expect(validParams).toEqual({ bounds: 'bounds value' }); }); it('returns filter parameters', () => { - const validParams = pickSearchParamsOnly(urlParams, filters); + const validParams = pickSearchParamsOnly(urlParams, filters, sortConfig); expect(validParams).toEqual(urlParams); }); it('drops an invalid filter param value', () => { const params = { pub_certificate: '200h', pub_yogaStyles: 'invalid1,invalid2' }; - const validParams = pickSearchParamsOnly(params, filters); + const validParams = pickSearchParamsOnly(params, filters, sortConfig); expect(validParams).toEqual({ pub_certificate: '200h' }); }); it('drops non-search params', () => { const params = { pub_certificate: '200h', other_param: 'somevalue' }; - const validParams = pickSearchParamsOnly(params, filters); + const validParams = pickSearchParamsOnly(params, filters, sortConfig); expect(validParams).toEqual({ pub_certificate: '200h' }); }); + + it('returns sort param', () => { + const params = { sort: '-price', other_param: 'somevalue' }; + const validParams = pickSearchParamsOnly(params, filters, sortConfig); + expect(validParams).toEqual({ sort: '-price' }); + }); }); }); diff --git a/src/containers/SearchPage/SearchPage.js b/src/containers/SearchPage/SearchPage.js index 0b12919b2..9296d29c8 100644 --- a/src/containers/SearchPage/SearchPage.js +++ b/src/containers/SearchPage/SearchPage.js @@ -1,5 +1,5 @@ import React, { Component } from 'react'; -import { array, bool, func, number, oneOf, object, shape, string } from 'prop-types'; +import { array, bool, func, oneOf, object, shape, string } from 'prop-types'; import { injectIntl, intlShape } from '../../util/reactIntl'; import { connect } from 'react-redux'; import { compose } from 'redux'; @@ -45,51 +45,11 @@ export class SearchPageComponent extends Component { this.searchMapListingsInProgress = false; - this.filters = this.filters.bind(this); this.onMapMoveEnd = debounce(this.onMapMoveEnd.bind(this), SEARCH_WITH_MAP_DEBOUNCE); this.onOpenMobileModal = this.onOpenMobileModal.bind(this); this.onCloseMobileModal = this.onCloseMobileModal.bind(this); } - filters() { - const { - certificateConfig, - yogaStylesConfig, - priceFilterConfig, - keywordFilterConfig, - dateRangeLengthFilterConfig, - } = this.props; - - // Note: "certificate" and "yogaStyles" filters are not actually filtering anything by default. - // Currently, if you want to use them, we need to manually configure them to be available - // for search queries. Read more from extended data document: - // https://www.sharetribe.com/docs/references/extended-data/#data-schema - - return { - priceFilter: { - paramName: 'price', - config: priceFilterConfig, - }, - dateRangeLengthFilter: { - paramName: 'dates', - minDurationParamName: 'minDuration', - config: dateRangeLengthFilterConfig, - }, - keywordFilter: { - paramName: 'keywords', - config: keywordFilterConfig, - }, - certificateFilter: { - paramName: 'pub_certificate', - options: certificateConfig.filter(c => !c.hideFromFilters), - }, - yogaStylesFilter: { - paramName: 'pub_yogaStyles', - options: yogaStylesConfig, - }, - }; - } - // Callback to determine if new search is needed // when map is moved by user or viewport has changed onMapMoveEnd(viewportBoundsChanged, data) { @@ -108,7 +68,7 @@ export class SearchPageComponent extends Component { // we start to react to "mapmoveend" events by generating new searches // (i.e. 'moveend' event in Mapbox and 'bounds_changed' in Google Maps) if (viewportBoundsChanged && isSearchPage) { - const { history, location } = this.props; + const { history, location, filterConfig } = this.props; // parse query parameters, including a custom attribute named certificate const { address, bounds, mapSearch, ...rest } = parse(location.search, { @@ -124,7 +84,7 @@ export class SearchPageComponent extends Component { ...originMaybe, bounds: viewportBounds, mapSearch: true, - ...validFilterParams(rest, this.filters()), + ...validFilterParams(rest, filterConfig), }; history.push(createResourceLocatorString('SearchPage', routes, {}, searchParams)); @@ -147,6 +107,9 @@ export class SearchPageComponent extends Component { const { intl, listings, + filterConfig, + sortConfig, + history, location, mapListings, onManageDisableScrolling, @@ -159,23 +122,23 @@ export class SearchPageComponent extends Component { onActivateListing, } = this.props; // eslint-disable-next-line no-unused-vars - const { mapSearch, page, sort, ...searchInURL } = parse(location.search, { + const { mapSearch, page, ...searchInURL } = parse(location.search, { latlng: ['origin'], 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, filters); + const urlQueryParams = pickSearchParamsOnly(searchInURL, filterConfig, sortConfig); // Page transition might initially use values from previous search const urlQueryString = stringify(urlQueryParams); - const paramsQueryString = stringify(pickSearchParamsOnly(searchParams, filters)); + const paramsQueryString = stringify( + pickSearchParamsOnly(searchParams, filterConfig, sortConfig) + ); const searchParamsAreInSync = urlQueryString === paramsQueryString; - const validQueryParams = validURLParamsForExtendedData(searchInURL, filters); + const validQueryParams = validURLParamsForExtendedData(searchInURL, filterConfig); const isWindowDefined = typeof window !== 'undefined'; const isMobileLayout = isWindowDefined && window.innerWidth < MODAL_BREAKPOINT; @@ -214,7 +177,6 @@ export class SearchPageComponent extends Component {
{ - const { name, id, certificate, intl } = props; + const { name, id, certificateOptions, intl } = props; const certificateLabel = intl.formatMessage({ id: 'EditListingDescriptionForm.certificateLabel', }); - return certificate ? ( + return certificateOptions ? ( - {certificate.map(c => ( + {certificateOptions.map(c => ( diff --git a/src/forms/EditListingDescriptionForm/EditListingDescriptionForm.js b/src/forms/EditListingDescriptionForm/EditListingDescriptionForm.js index c320e0ad0..7cc95f0f8 100644 --- a/src/forms/EditListingDescriptionForm/EditListingDescriptionForm.js +++ b/src/forms/EditListingDescriptionForm/EditListingDescriptionForm.js @@ -18,7 +18,7 @@ const EditListingDescriptionFormComponent = props => ( {...props} render={formRenderProps => { const { - certificate, + certificateOptions, className, disabled, ready, @@ -112,7 +112,7 @@ const EditListingDescriptionFormComponent = props => ( @@ -147,7 +147,7 @@ EditListingDescriptionFormComponent.propTypes = { showListingsError: propTypes.error, updateListingError: propTypes.error, }), - certificate: arrayOf( + certificateOptions: arrayOf( shape({ key: string.isRequired, label: string.isRequired, diff --git a/src/forms/EditListingFeaturesForm/EditListingFeaturesForm.example.js b/src/forms/EditListingFeaturesForm/EditListingFeaturesForm.example.js index e13daab54..8347b8d9e 100644 --- a/src/forms/EditListingFeaturesForm/EditListingFeaturesForm.example.js +++ b/src/forms/EditListingFeaturesForm/EditListingFeaturesForm.example.js @@ -5,6 +5,27 @@ const NAME = 'yogaStyles'; const initialValueArray = ['hatha', 'vinyasa', 'yin']; const initialValues = { [NAME]: initialValueArray }; +const filterConfig = [ + { + id: 'yogaStyles', + label: 'Yoga styles', + type: 'SelectMultipleFilter', + group: 'secondary', + queryParamNames: ['pub_yogaStyles'], + config: { + mode: 'has_all', + options: [ + { key: 'ashtanga', label: 'Ashtanga' }, + { key: 'hatha', label: 'Hatha' }, + { key: 'kundalini', label: 'Kundalini' }, + { key: 'restorative', label: 'Restorative' }, + { key: 'vinyasa', label: 'Vinyasa' }, + { key: 'yin', label: 'Yin' }, + ], + }, + }, +]; + export const YogaStyles = { component: EditListingFeaturesForm, props: { @@ -16,6 +37,7 @@ export const YogaStyles = { updateInProgress: false, disabled: false, ready: false, + filterConfig, }, group: 'forms', }; diff --git a/src/forms/EditListingFeaturesForm/EditListingFeaturesForm.js b/src/forms/EditListingFeaturesForm/EditListingFeaturesForm.js index 8e5d502a2..652090e3c 100644 --- a/src/forms/EditListingFeaturesForm/EditListingFeaturesForm.js +++ b/src/forms/EditListingFeaturesForm/EditListingFeaturesForm.js @@ -4,7 +4,7 @@ import classNames from 'classnames'; import { Form as FinalForm } from 'react-final-form'; import arrayMutators from 'final-form-arrays'; import { FormattedMessage } from '../../util/reactIntl'; - +import { findOptionsForSelectFilter } from '../../util/search'; import { propTypes } from '../../util/types'; import config from '../../config'; import { Button, FieldCheckboxGroup, Form } from '../../components'; @@ -28,6 +28,7 @@ const EditListingFeaturesFormComponent = props => ( updated, updateInProgress, fetchErrors, + filterConfig, } = formRenderProps; const classes = classNames(rootClassName || css.root, className); @@ -48,17 +49,13 @@ const EditListingFeaturesFormComponent = props => (

) : null; + const options = findOptionsForSelectFilter('yogaStyles', filterConfig); return (
{errorMessage} {errorMessageShowListing} - +