Create KeywordFilter

Add it to desktop and mobile layout
This commit is contained in:
Jenni Nurmi 2019-07-05 11:44:50 +03:00 committed by Vesa Luusua
parent 578a02039a
commit 2485b46b30
11 changed files with 447 additions and 2 deletions

View file

@ -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;
}

View file

@ -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 (
<KeywordFilter
id="KeywordFilterPopupExample"
name="keyword"
urlParam={URL_PARAM}
label="Keyword"
onSubmit={(urlParam, values) => 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 (
<KeywordFilter
id="KeywordFilterPlainExample"
name="keyword"
urlParam={URL_PARAM}
label="Keyword"
onSubmit={(urlParam, values) => {
handleSubmit(urlParam, values, history);
}}
showAsPopup={false}
liveEdit={true}
initialValues={initialValues}
/>
);
});
export const KeywordFilterPlainExample = {
component: KeywordFilterPlain,
props: {},
group: 'filters',
};

View file

@ -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 ? (
<FilterPopup
className={classes}
rootClassName={rootClassName}
popupClassName={css.popupSize}
name={name}
label={labelForPopup}
isSelected={hasInitialValues}
id={`${id}.popup`}
showAsPopup
labelMaxWidth={250}
contentPlacementOffset={contentPlacementOffset}
onSubmit={handleSubmit}
initialValues={namedInitialValues}
urlParam={urlParam}
keepDirtyOnReinitialize
{...rest}
>
<FieldTextInput
className={css.field}
name={name}
id={`${id}-input`}
type="text"
label={filterText}
placeholder={placeholder}
autoComplete="off"
/>
</FilterPopup>
) : (
<FilterPlain
className={className}
rootClassName={rootClassName}
label={labelForPlain}
isSelected={hasInitialValues}
id={`${id}.plain`}
liveEdit
contentPlacementOffset={contentStyle}
onSubmit={handleChangeWithDebounce}
onClear={handleClear}
initialValues={namedInitialValues}
urlParam={urlParam}
{...rest}
>
<fieldset className={css.fieldPlain}>
<label>{filterText}</label>
<FieldTextInput
name={name}
id={`${id}-input`}
isUncontrolled
inputRef={this.mobileInputRef}
type="text"
placeholder={placeholder}
autoComplete="off"
/>
</fieldset>
</FilterPlain>
);
}
}
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);

View file

@ -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 ? (
<SelectSingleFilter
urlParam={categoryFilter.paramName}
@ -197,6 +215,20 @@ const SearchFiltersComponent = props => {
/>
) : null;
const keywordFilterElement =
keywordFilter && keywordFilter.config.active ? (
<KeywordFilter
id={'SearchFilters.keywordFilter'}
name="keyword"
urlParam={keywordFilter.paramName}
label={keywordLabel}
onSubmit={handleKeyword}
showAsPopup
initialValues={initialKeyword}
contentPlacementOffset={FILTER_DROPDOWN_OFFSET}
/>
) : null;
const toggleSearchFiltersPanelButtonClasses =
isSearchFiltersPanelOpen || searchFiltersPanelSelectedCount > 0
? css.searchFiltersPanelOpen
@ -221,6 +253,7 @@ const SearchFiltersComponent = props => {
{amenitiesFilterElement}
{priceFilterElement}
{dateRangeFilterElement}
{keywordFilterElement}
{toggleSearchFiltersPanelButton}
</div>

View file

@ -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 ? (
<BookingDateRangeFilter
id="SearchFilters.dateRangeFilter"
id="SearchFiltersMobile.dateRangeFilter"
urlParam={dateRangeFilter.paramName}
onSubmit={this.handleDateRange}
liveEdit
@ -267,6 +279,24 @@ class SearchFiltersMobileComponent extends Component {
/>
) : null;
const initialKeyword = this.initialValue(keywordFilter.paramName);
const keywordLabel = intl.formatMessage({
id: 'SearchFiltersMobile.keywordLabel',
});
const keywordFilterElement =
keywordFilter && keywordFilter.config.active ? (
<KeywordFilter
id={'SearchFiltersMobile.keywordFilter'}
name="keyword"
urlParam={keywordFilter.paramName}
label={keywordLabel}
onSubmit={this.handleKeyword}
liveEdit
showAsPopup={false}
initialValues={initialKeyword}
/>
) : null;
return (
<div className={classes}>
<div className={css.searchResultSummary}>
@ -299,6 +329,7 @@ class SearchFiltersMobileComponent extends Component {
</div>
{this.state.isFiltersOpenOnMobile ? (
<div className={css.filtersWrapper}>
{keywordFilterElement}
{categoryFilterElement}
{amenitiesFilterElement}
{priceFilterElement}

View file

@ -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';

View file

@ -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,
}}
/>
<ModalInMobile
@ -267,6 +278,7 @@ SearchPageComponent.defaultProps = {
amenities: config.custom.amenities,
priceFilterConfig: config.custom.priceFilterConfig,
dateRangeFilterConfig: config.custom.dateRangeFilterConfig,
keywordFilterConfig: config.custom.keywordFilterConfig,
activeListingId: null,
};

View file

@ -72,6 +72,12 @@ exports[`SearchPageComponent matches snapshot 1`] = `
},
"paramName": "dates",
},
"keywordFilter": Object {
"config": Object {
"active": true,
},
"paramName": "keywords",
},
"priceFilter": Object {
"config": Object {
"max": 1000,

View file

@ -42,6 +42,7 @@ import * as IconSocialMediaInstagram from './components/IconSocialMediaInstagram
import * as IconSocialMediaTwitter from './components/IconSocialMediaTwitter/IconSocialMediaTwitter.example';
import * as IconSpinner from './components/IconSpinner/IconSpinner.example';
import * as ImageCarousel from './components/ImageCarousel/ImageCarousel.example';
import * as KeywordFilter from './components/KeywordFilter/KeywordFilter.example';
import * as ListingCard from './components/ListingCard/ListingCard.example';
import * as LocationAutocompleteInput from './components/LocationAutocompleteInput/LocationAutocompleteInput.example';
import * as ManageListingCard from './components/ManageListingCard/ManageListingCard.example';
@ -148,6 +149,7 @@ export {
IconSocialMediaTwitter,
IconSpinner,
ImageCarousel,
KeywordFilter,
ListingCard,
LocationAutocompleteInput,
LoginForm,

View file

@ -56,3 +56,8 @@ export const priceFilterConfig = {
export const dateRangeFilterConfig = {
active: true,
};
// Activate keyword filter on search page
export const keywordFilterConfig = {
active: true,
};

View file

@ -310,6 +310,11 @@
"InboxPage.statePendingPayment": "Pending payment",
"InboxPage.stateRequested": "Requested",
"InboxPage.title": "Inbox",
"KeywordFilter.filterText": "Filter results by",
"KeywordFilter.labelSelected": "\"{labelText}\"",
"KeywordFilter.placeholder": "Enter a keyword…",
"KeywordFilterPlainForm.labelSelected": "Keyword",
"KeywordFilterPlainForm.placeholder": "Enter a keyword…",
"LandingPage.schemaDescription": "Book a sauna using Saunatime or earn some income by sharing your sauna",
"LandingPage.schemaTitle": "Book saunas everywhere | {siteTitle}",
"ListingCard.hostedBy": "Hosted by {authorName}.",
@ -682,6 +687,7 @@
"SearchFilters.categoryLabel": "Category",
"SearchFilters.filtersButtonLabel": "Filters",
"SearchFilters.foundResults": "{count, number} {count, plural, one {result} other {results}}",
"SearchFilters.keywordLabel": "Keyword",
"SearchFilters.loadingResults": "Loading search results…",
"SearchFilters.loadingResultsMobile": "Loading…",
"SearchFilters.moreFiltersButton": "{count, plural, =0 {More filters} other {More filters • #}}",
@ -692,6 +698,7 @@
"SearchFiltersMobile.cancel": "CANCEL",
"SearchFiltersMobile.categoryLabel": "Category",
"SearchFiltersMobile.heading": "Filter saunas",
"SearchFiltersMobile.keywordLabel": "Keyword",
"SearchFiltersMobile.resetAll": "Reset all",
"SearchFiltersMobile.showListings": "Show {count, number} {count, plural, one {sauna} other {saunas}}",
"SearchFiltersPanel.apply": "Apply",