diff --git a/src/components/KeywordFilter/KeywordFilter.css b/src/components/KeywordFilter/KeywordFilter.css new file mode 100644 index 00000000..8384ce72 --- /dev/null +++ b/src/components/KeywordFilter/KeywordFilter.css @@ -0,0 +1,65 @@ +@import '../../marketplace.css'; + +.root { + position: relative; + display: inline-block; +} + +.label { + @apply --marketplaceButtonStylesSecondary; + @apply --marketplaceSearchFilterLabelFontStyles; + + padding: 9px 16px 10px 16px; + width: auto; + height: auto; + min-height: 0; + border-radius: 4px; + + &:focus { + outline: none; + background-color: var(--matterColorLight); + border-color: transparent; + text-decoration: none; + box-shadow: var(--boxShadowFilterButton); + } +} + +.labelSelected { + @apply --marketplaceButtonStyles; + @apply --marketplaceSearchFilterLabelFontStyles; + font-weight: var(--fontWeightSemiBold); + + padding: 9px 16px 10px 16px; + width: auto; + height: auto; + min-height: 0; + border-radius: 4px; + border: 1px solid var(--marketplaceColor); + + &:hover, + &:focus { + border: 1px solid var(--marketplaceColorDark); + } +} + +.labelText { + display: inline-block; + max-width: 250px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.field { + @apply --marketplaceH4FontStyles; + margin: 0; + padding: 1px 0 13px 0; + border: none; +} + +.fieldPlain { + @apply --marketplaceH4FontStyles; + margin: 0; + padding: 16px 0 30px 20px; + border: none; +} diff --git a/src/components/KeywordFilter/KeywordFilter.example.js b/src/components/KeywordFilter/KeywordFilter.example.js new file mode 100644 index 00000000..05080320 --- /dev/null +++ b/src/components/KeywordFilter/KeywordFilter.example.js @@ -0,0 +1,69 @@ +import React from 'react'; +import { withRouter } from 'react-router-dom'; +import KeywordFilter from './KeywordFilter'; +import { stringify, parse } from '../../util/urlHelpers'; + +const URL_PARAM = 'keywords'; + +const handleSubmit = (urlParam, values, history) => { + console.log('Submitting values', values); + const queryParams = values ? `?${stringify({ [urlParam]: values })}` : ''; + history.push(`${window.location.pathname}${queryParams}`); +}; + +const KeywordFilterPopup = withRouter(props => { + const { history, location } = props; + + const params = parse(location.search); + const keyword = params[URL_PARAM]; + const initialValues = !!keyword ? keyword : ''; + + return ( + handleSubmit(urlParam, values, history)} + showAsPopup={true} + liveEdit={false} + initialValues={initialValues} + contentPlacementOffset={-14} + /> + ); +}); + +export const KeywordFilterPopupExample = { + component: KeywordFilterPopup, + props: {}, + group: 'filters', +}; + +const KeywordFilterPlain = withRouter(props => { + const { history, location } = props; + + const params = parse(location.search); + const keyword = params[URL_PARAM]; + const initialValues = !!keyword ? keyword : ''; + + return ( + { + handleSubmit(urlParam, values, history); + }} + showAsPopup={false} + liveEdit={true} + initialValues={initialValues} + /> + ); +}); + +export const KeywordFilterPlainExample = { + component: KeywordFilterPlain, + props: {}, + group: 'filters', +}; diff --git a/src/components/KeywordFilter/KeywordFilter.js b/src/components/KeywordFilter/KeywordFilter.js new file mode 100644 index 00000000..ab16a317 --- /dev/null +++ b/src/components/KeywordFilter/KeywordFilter.js @@ -0,0 +1,214 @@ +import React, { Component } from 'react'; +import { func, number, string } from 'prop-types'; +import classNames from 'classnames'; +import { injectIntl, intlShape } from 'react-intl'; +import debounce from 'lodash/debounce'; +import { FieldTextInput } from '../../components'; + +import { FilterPopup, FilterPlain } from '../../components'; +import css from './KeywordFilter.css'; + +// When user types, we wait for new keystrokes a while before searching new content +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; + +class KeywordFilter extends Component { + constructor(props) { + super(props); + + this.filter = null; + this.filterContent = null; + this.shortKeywordTimeout = null; + this.mobileInputRef = React.createRef(); + + this.positionStyleForContent = this.positionStyleForContent.bind(this); + } + + componentWillUnmount() { + window.clearTimeout(this.shortKeywordTimeout); + } + + positionStyleForContent() { + if (this.filter && this.filterContent) { + // Render the filter content to the right from the menu + // unless there's no space in which case it is rendered + // to the left + const distanceToRight = window.innerWidth - this.filter.getBoundingClientRect().right; + const labelWidth = this.filter.offsetWidth; + const contentWidth = this.filterContent.offsetWidth; + const contentWidthBiggerThanLabel = contentWidth - labelWidth; + const renderToRight = distanceToRight > contentWidthBiggerThanLabel; + const contentPlacementOffset = this.props.contentPlacementOffset; + + const offset = renderToRight + ? { left: contentPlacementOffset } + : { right: contentPlacementOffset }; + // set a min-width if the content is narrower than the label + const minWidth = contentWidth < labelWidth ? { minWidth: labelWidth } : null; + + return { ...offset, ...minWidth }; + } + return {}; + } + + render() { + const { + rootClassName, + className, + id, + name, + label, + initialValues, + contentPlacementOffset, + onSubmit, + urlParam, + intl, + showAsPopup, + ...rest + } = this.props; + + const classes = classNames(rootClassName || css.root, className); + + const hasInitialValues = !!initialValues && initialValues.length > 0; + const labelForPopup = hasInitialValues + ? intl.formatMessage({ id: 'KeywordFilter.labelSelected' }, { labelText: initialValues }) + : label; + + const labelForPlain = hasInitialValues + ? intl.formatMessage( + { id: 'KeywordFilterPlainForm.labelSelected' }, + { labelText: initialValues } + ) + : label; + + const filterText = intl.formatMessage({ id: 'KeywordFilter.filterText' }); + + const placeholder = intl.formatMessage({ id: 'KeywordFilter.placeholder' }); + + const contentStyle = this.positionStyleForContent(); + + // pass the initial values with the name key so that + // they can be passed to the correct field + const namedInitialValues = { [name]: initialValues }; + + const handleSubmit = (urlParam, values) => { + const usedValue = values ? values[name] : values; + onSubmit(urlParam, usedValue); + }; + + const debouncedSubmit = debounce(handleSubmit, DEBOUNCE_WAIT_TIME, { + leading: false, + 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. + // 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 (this.shortKeywordTimeout) { + window.clearTimeout(this.shortKeywordTimeout); + } + return debouncedSubmit(urlParam, 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); + }, TIMEOUT_FOR_SHORT_QUERIES); + } + }; + + // Uncontrolled input needs to be cleared through the reference to DOM element. + const handleClear = () => { + if (this.mobileInputRef && this.mobileInputRef.current) { + this.mobileInputRef.current.value = ''; + } + }; + + return showAsPopup ? ( + + + + ) : ( + +
+ + +
+
+ ); + } +} + +KeywordFilter.defaultProps = { + rootClassName: null, + className: null, + initialValues: null, + contentPlacementOffset: 0, +}; + +KeywordFilter.propTypes = { + rootClassName: string, + className: string, + id: string.isRequired, + name: string.isRequired, + urlParam: string.isRequired, + label: string.isRequired, + onSubmit: func.isRequired, + initialValues: string, + contentPlacementOffset: number, + + // form injectIntl + intl: intlShape.isRequired, +}; + +export default injectIntl(KeywordFilter); diff --git a/src/components/SearchFilters/SearchFilters.js b/src/components/SearchFilters/SearchFilters.js index a7315962..d9a7a66d 100644 --- a/src/components/SearchFilters/SearchFilters.js +++ b/src/components/SearchFilters/SearchFilters.js @@ -11,6 +11,7 @@ import { SelectSingleFilter, SelectMultipleFilter, PriceFilter, + KeywordFilter, } from '../../components'; import routeConfiguration from '../../routeConfiguration'; import { parseDateFromISO8601, stringifyDateToISO8601 } from '../../util/dates'; @@ -70,6 +71,7 @@ const SearchFiltersComponent = props => { amenitiesFilter, priceFilter, dateRangeFilter, + keywordFilter, isSearchFiltersPanelOpen, toggleSearchFiltersPanel, searchFiltersPanelSelectedCount, @@ -88,6 +90,10 @@ const SearchFiltersComponent = props => { id: 'SearchFilters.amenitiesLabel', }); + const keywordLabel = intl.formatMessage({ + id: 'SearchFilters.keywordLabel', + }); + const initialAmenities = amenitiesFilter ? initialValues(urlQueryParams, amenitiesFilter.paramName) : null; @@ -104,6 +110,10 @@ const SearchFiltersComponent = props => { ? initialDateRangeValue(urlQueryParams, dateRangeFilter.paramName) : null; + const initialKeyword = keywordFilter + ? initialValue(urlQueryParams, keywordFilter.paramName) + : null; + const handleSelectOptions = (urlParam, options) => { const queryParams = options && options.length > 0 @@ -147,6 +157,14 @@ const SearchFiltersComponent = props => { 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 categoryFilterElement = categoryFilter ? ( { /> ) : null; + const keywordFilterElement = + keywordFilter && keywordFilter.config.active ? ( + + ) : null; + const toggleSearchFiltersPanelButtonClasses = isSearchFiltersPanelOpen || searchFiltersPanelSelectedCount > 0 ? css.searchFiltersPanelOpen @@ -221,6 +253,7 @@ const SearchFiltersComponent = props => { {amenitiesFilterElement} {priceFilterElement} {dateRangeFilterElement} + {keywordFilterElement} {toggleSearchFiltersPanelButton} diff --git a/src/components/SearchFiltersMobile/SearchFiltersMobile.js b/src/components/SearchFiltersMobile/SearchFiltersMobile.js index 0c098b50..b582e049 100644 --- a/src/components/SearchFiltersMobile/SearchFiltersMobile.js +++ b/src/components/SearchFiltersMobile/SearchFiltersMobile.js @@ -11,6 +11,7 @@ import { createResourceLocatorString } from '../../util/routes'; import { ModalInMobile, Button, + KeywordFilter, PriceFilter, SelectSingleFilter, SelectMultipleFilter, @@ -34,6 +35,7 @@ class SearchFiltersMobileComponent extends Component { this.handleSelectMultiple = this.handleSelectMultiple.bind(this); this.handlePrice = this.handlePrice.bind(this); this.handleDateRange = this.handleDateRange.bind(this); + this.handleKeyword = this.handleKeyword.bind(this); this.initialValue = this.initialValue.bind(this); this.initialValues = this.initialValues.bind(this); this.initialPriceRangeValue = this.initialPriceRangeValue.bind(this); @@ -118,6 +120,15 @@ class SearchFiltersMobileComponent extends Component { 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)); + } + // Reset all filter query parameters resetAll(e) { const { urlQueryParams, history, filterParamNames } = this.props; @@ -185,6 +196,7 @@ class SearchFiltersMobileComponent extends Component { amenitiesFilter, priceFilter, dateRangeFilter, + keywordFilter, intl, } = this.props; @@ -258,7 +270,7 @@ class SearchFiltersMobileComponent extends Component { const dateRangeFilterElement = dateRangeFilter && dateRangeFilter.config.active ? ( ) : null; + const initialKeyword = this.initialValue(keywordFilter.paramName); + const keywordLabel = intl.formatMessage({ + id: 'SearchFiltersMobile.keywordLabel', + }); + const keywordFilterElement = + keywordFilter && keywordFilter.config.active ? ( + + ) : null; + return (
@@ -299,6 +329,7 @@ class SearchFiltersMobileComponent extends Component {
{this.state.isFiltersOpenOnMobile ? (
+ {keywordFilterElement} {categoryFilterElement} {amenitiesFilterElement} {priceFilterElement} diff --git a/src/components/index.js b/src/components/index.js index 7f79a06c..aee68ee0 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -119,6 +119,7 @@ export { default as BookingPanel } from './BookingPanel/BookingPanel'; export { default as Discussion } from './Discussion/Discussion'; export { default as FilterPlain } from './FilterPlain/FilterPlain'; export { default as FilterPopup } from './FilterPopup/FilterPopup'; +export { default as KeywordFilter } from './KeywordFilter/KeywordFilter'; export { default as ListingCard } from './ListingCard/ListingCard'; export { default as ManageListingCard } from './ManageListingCard/ManageListingCard'; export { default as Map } from './Map/Map'; diff --git a/src/containers/SearchPage/SearchPage.js b/src/containers/SearchPage/SearchPage.js index 774c228e..9cad11f2 100644 --- a/src/containers/SearchPage/SearchPage.js +++ b/src/containers/SearchPage/SearchPage.js @@ -52,7 +52,13 @@ export class SearchPageComponent extends Component { } filters() { - const { categories, amenities, priceFilterConfig, dateRangeFilterConfig } = this.props; + const { + categories, + amenities, + priceFilterConfig, + dateRangeFilterConfig, + keywordFilterConfig, + } = this.props; // Note: "category" and "amenities" filters are not actually filtering anything by default. // Currently, if you want to use them, we need to manually configure them to be available @@ -76,6 +82,10 @@ export class SearchPageComponent extends Component { paramName: 'dates', config: dateRangeFilterConfig, }, + keywordFilter: { + paramName: 'keywords', + config: keywordFilterConfig, + }, }; } @@ -220,6 +230,7 @@ export class SearchPageComponent extends Component { amenitiesFilter: filters.amenitiesFilter, priceFilter: filters.priceFilter, dateRangeFilter: filters.dateRangeFilter, + keywordFilter: filters.keywordFilter, }} />