From 2485b46b30da676bc7980491c4b997c121e19d47 Mon Sep 17 00:00:00 2001 From: Jenni Nurmi Date: Fri, 5 Jul 2019 11:44:50 +0300 Subject: [PATCH 1/6] Create KeywordFilter Add it to desktop and mobile layout --- .../KeywordFilter/KeywordFilter.css | 65 ++++++ .../KeywordFilter/KeywordFilter.example.js | 69 ++++++ src/components/KeywordFilter/KeywordFilter.js | 214 ++++++++++++++++++ src/components/SearchFilters/SearchFilters.js | 33 +++ .../SearchFiltersMobile.js | 33 ++- src/components/index.js | 1 + src/containers/SearchPage/SearchPage.js | 14 +- .../__snapshots__/SearchPage.test.js.snap | 6 + src/examples.js | 2 + src/marketplace-custom-config.js | 5 + src/translations/en.json | 7 + 11 files changed, 447 insertions(+), 2 deletions(-) create mode 100644 src/components/KeywordFilter/KeywordFilter.css create mode 100644 src/components/KeywordFilter/KeywordFilter.example.js create mode 100644 src/components/KeywordFilter/KeywordFilter.js 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, }} /> Date: Tue, 9 Jul 2019 15:25:03 +0300 Subject: [PATCH 2/6] Add code comments about restriction tha this can't be used with origin --- src/config.js | 2 ++ src/marketplace-custom-config.js | 3 +++ 2 files changed, 5 insertions(+) diff --git a/src/config.js b/src/config.js index 13e5560f..ff346017 100644 --- a/src/config.js +++ b/src/config.js @@ -22,6 +22,8 @@ const i18n = { // Should search results be ordered by distance to origin. // NOTE: If this is set to true add parameter 'origin' to every location in default-location-searches.js // Without the 'origin' parameter, search will not work correctly +// NOTE: Keyword search and ordering search results by distance can't be used at the same time. You can turn keyword +// search off by changing the keywordFilterConfig parameter active to false in marketplace-custom-config.js const sortSearchByDistance = false; // API supports custom processes to be used in booking process. diff --git a/src/marketplace-custom-config.js b/src/marketplace-custom-config.js index eb3416ec..3cd8eedb 100644 --- a/src/marketplace-custom-config.js +++ b/src/marketplace-custom-config.js @@ -58,6 +58,9 @@ export const dateRangeFilterConfig = { }; // Activate keyword filter on search page + +// NOTE: If you are ordering search results by distance the keyword search can't be used at the same time. +// You can turn off ordering by distance in config.js file export const keywordFilterConfig = { active: true, }; From cb242413f86974c0260dd739add9c3c2c51c46d5 Mon Sep 17 00:00:00 2001 From: Jenni Nurmi Date: Thu, 11 Jul 2019 14:34:08 +0300 Subject: [PATCH 3/6] Ensure that values are string before validating them --- src/containers/SearchPage/SearchPage.helpers.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/containers/SearchPage/SearchPage.helpers.js b/src/containers/SearchPage/SearchPage.helpers.js index 0be2ba0d..6a8a102c 100644 --- a/src/containers/SearchPage/SearchPage.helpers.js +++ b/src/containers/SearchPage/SearchPage.helpers.js @@ -13,11 +13,12 @@ import routeConfiguration from '../../routeConfiguration'; * @param {Object} paramValue Search parameter value * @param {Object} filters Filters configuration */ -export const validURLParamForExtendedData = (paramName, paramValue, filters) => { +export const validURLParamForExtendedData = (paramName, paramValueRaw, filters) => { const filtersArray = Object.values(filters); // resolve configuration for this filter const filterConfig = filtersArray.find(f => f.paramName === paramName); + const paramValue = paramValueRaw.toString(); const valueArray = paramValue ? paramValue.split(',') : []; if (filterConfig && valueArray.length > 0) { From fb2c11cae2df78fad8e3cb5e577d134e224fc0af Mon Sep 17 00:00:00 2001 From: Vesa Luusua Date: Tue, 16 Jul 2019 14:19:32 +0300 Subject: [PATCH 4/6] Update FieldTextInput: use defaultValue if uncontrolled --- .../FieldTextInput/FieldTextInput.js | 32 +++++++++++++++++-- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/src/components/FieldTextInput/FieldTextInput.js b/src/components/FieldTextInput/FieldTextInput.js index 878738ee..368f5615 100644 --- a/src/components/FieldTextInput/FieldTextInput.js +++ b/src/components/FieldTextInput/FieldTextInput.js @@ -1,5 +1,5 @@ import React, { Component } from 'react'; -import { func, object, shape, string } from 'prop-types'; +import { bool, func, object, shape, string } from 'prop-types'; import { Field } from 'react-final-form'; import classNames from 'classnames'; import { ValidationError, ExpandingTextarea } from '../../components'; @@ -22,6 +22,8 @@ class FieldTextInputComponent extends Component { input, meta, onUnmount, + isUncontrolled, + inputRef, ...rest } = this.props; /* eslint-enable no-unused-vars */ @@ -41,6 +43,11 @@ class FieldTextInputComponent extends Component { const fieldMeta = { touched: hasError, error: errorText }; + // Uncontrolled input uses defaultValue instead of value. + const { value: defaultValue, ...inputWithoutValue } = input; + // Use inputRef if it is passed as prop. + const refMaybe = inputRef ? { ref: inputRef } : {}; + const inputClasses = inputRootClass || classNames(css.input, { @@ -48,9 +55,20 @@ class FieldTextInputComponent extends Component { [css.inputError]: hasError, [css.textarea]: isTextarea, }); + const maxLength = CONTENT_MAX_LENGTH; const inputProps = isTextarea - ? { className: inputClasses, id, rows: 1, maxLength: CONTENT_MAX_LENGTH, ...input, ...rest } - : { className: inputClasses, id, type, ...input, ...rest }; + ? { className: inputClasses, id, rows: 1, maxLength, ...refMaybe, ...input, ...rest } + : isUncontrolled + ? { + className: inputClasses, + id, + type, + defaultValue, + ...refMaybe, + ...inputWithoutValue, + ...rest, + } + : { className: inputClasses, id, type, ...refMaybe, ...input, ...rest }; const classes = classNames(rootClassName || css.root, className); return ( @@ -71,6 +89,8 @@ FieldTextInputComponent.defaultProps = { customErrorText: null, id: null, label: null, + isUncontrolled: false, + inputRef: null, }; FieldTextInputComponent.propTypes = { @@ -92,6 +112,12 @@ FieldTextInputComponent.propTypes = { // Either 'textarea' or something that is passed to the input element type: string.isRequired, + // Uncontrolled input uses defaultValue prop, but doesn't pass value from form to the field. + // https://reactjs.org/docs/uncontrolled-components.html#default-values + isUncontrolled: bool, + // a ref object passed for input element. + inputRef: object, + // Generated by final-form's Field component input: shape({ onChange: func.isRequired, From 8cf99fd6c0405f5c9ed628af2d83afb5ca5fda67 Mon Sep 17 00:00:00 2001 From: Vesa Luusua Date: Tue, 16 Jul 2019 14:20:40 +0300 Subject: [PATCH 5/6] Update FilterPopup: add labelMaxWidth prop and ellipsis: --- src/components/FilterPopup/FilterPopup.css | 5 +++++ src/components/FilterPopup/FilterPopup.js | 11 ++++++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/components/FilterPopup/FilterPopup.css b/src/components/FilterPopup/FilterPopup.css index f3d73f47..16257670 100644 --- a/src/components/FilterPopup/FilterPopup.css +++ b/src/components/FilterPopup/FilterPopup.css @@ -41,6 +41,11 @@ border: 1px solid var(--marketplaceColorDark); } } +.labelEllipsis { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} .popup { /* By default hide the content */ diff --git a/src/components/FilterPopup/FilterPopup.js b/src/components/FilterPopup/FilterPopup.js index a3fa4669..6f54a0e2 100644 --- a/src/components/FilterPopup/FilterPopup.js +++ b/src/components/FilterPopup/FilterPopup.js @@ -103,6 +103,7 @@ class FilterPopup extends Component { popupClassName, id, label, + labelMaxWidth, isSelected, children, initialValues, @@ -114,6 +115,8 @@ class FilterPopup extends Component { const popupClasses = classNames(css.popup, { [css.isOpen]: this.state.isOpen }); const popupSizeClasses = popupClassName || css.popupSize; const labelStyles = isSelected ? css.labelSelected : css.label; + const labelMaxWidthMaybe = labelMaxWidth ? { maxWidth: `${labelMaxWidth}px` } : {}; + const labelMaxWidthStyles = labelMaxWidth ? css.labelEllipsis : null; const contentStyle = this.positionStyleForContent(); return ( @@ -125,7 +128,11 @@ class FilterPopup extends Component { this.filter = node; }} > -
Date: Thu, 18 Jul 2019 17:46:29 +0300 Subject: [PATCH 6/6] Update changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 391d95b1..5fa682c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ way to update this template, but currently, we follow a pattern: ## Upcoming version 2019-XX-XX +- [add] Keyword search/filter added to SearchPage component. + [#1129](https://github.com/sharetribe/flex-template-web/pull/1129) - [fix] temporarily remove audit CI job. [#1136](https://github.com/sharetribe/flex-template-web/pull/1136) - [change] Update outdated dependencies. This includes updating lodash to fix the security issue.