diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a84e555..90731603 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] Date filter added and filter components (single and multiselect) are refactored to use + shared subcomponents. [#949](https://github.com/sharetribe/flex-template-web/pull/949) - [fix] Fixed copy-text in ReviewForm: Rating is required. [#1011](https://github.com/sharetribe/flex-template-web/pull/1011) - [change] Some of the documentation moved to Flex Docs: https://www.sharetribe.com/docs/ diff --git a/docs/search-filters.md b/docs/search-filters.md index b8057d2b..89582094 100644 --- a/docs/search-filters.md +++ b/docs/search-filters.md @@ -3,28 +3,31 @@ The search experience can be improved by adding search filters to narrow down the results. The filters rely on listing's indexed data. +- [Filter types](#filter-types) +- [Adding a new search filter](#adding-a-new-search-filter) + - [Common changes](#common-changes) + - [Desktop filters](#desktop-filters) + - [Desktop filters panel](#desktop-filters-panel) + - [Mobile filters](#mobile-filters) +- [Creating your own filter types](#creating-your-own-filter-types) + ## Filter types -The Flex template for web has three different filter types: _price filter_, _select single_ and -_select multiple_. The _price filter_ is for the specific case of filtering listings with a price -range. +The Flex template for web has different filter types: _price_, _date range_, _select single_ and +_select multiple_. Select single and select multiple filters are generic in a way that they can be +used to filter search results using different kinds of data. The price and date range filters on the +other hand are only used for filtering by price and date range. > NOTE: price filter should be configured from `src/marketplace-custom-config.js`. Current maximum > value for the range is set to 1000 (USD/EUR). -Other two filter types can be used with extended data. The _select single_ one can be used to filter -out search result with only one value per search parameter. The _select multiple_ filters on the -other hand can take multiple values for a single search parameter. These two filter types for -extended data are implemented with four different components, a standard and a plain one: +Filters _select single_ and _select multiple_ can be used with extended data. The _select single_ +one can be used to filter out search results with only one value per search parameter. The _select +multiple_ filters on the other hand can take multiple values for a single search parameter. These +two filter types for extended data are implemented with two different components: -- Select single filter: `SelectSingleFilter` and `SelectSingleFilterPlain` -- Select multiple filter: `SelectMultipleFilter` and `SelectMultipleFilterPlain` - -The `SelectSingleFilter` and `SelectMultipleFilter` components are rendered as standard dropdowns in -the search view. The plain filter components `SelectSingleFilterPlain` and -`SelectMultipleFilterPlain` are used with `SearchFiltersMobile` and `SearchFiltersPanel` to provider -filters in mobile view or to open filters in a distinct panel in order to fit more filters to the -desktop search view. +- Select single filter: `SelectSingleFilter` +- Select multiple filter: `SelectMultipleFilter` ## Adding a new search filter @@ -147,7 +150,7 @@ desktop filters, perform the following in `SearchFilters` component: - declare a prop with the same name that you added the filter config to `primaryFilters` - resolve the filters initial value with `initialValue` and `initialValues` functions - render the filter by using a `SelectSingleFilter` or `SelectMultipleFilter` component inside the - `
` element + `
` element. ### Desktop filters panel @@ -161,19 +164,31 @@ To use the `SearchFiltersPanel`, do the following: - declare a prop with the same name that you added the filter config to `secondaryFilters` - resolve the filters initial value with `initialValue` and `initialValues` methods -- use the `SelectSingleFilterPlain` and `SelectMultipleFilterPlain` components inside the - `
` element to render the filters +- use the `SelectSingleFilter` and `SelectMultipleFilter` components inside the + `
` element to render the filters. ### Mobile filters ![Mobile filters](./assets/search-filters/mobile-filters.png) -The mobile view uses the same `SelectSingleFilterPlain` and `SelectMultipleFilterPlain` components -as the filter panel. In this case the filter components are declared in `SearchFiltersMobile`. The +The mobile view uses the same `SelectSingleFilter` and `SelectMultipleFilter` components as the +filter panel. In this case the filter components are declared in `SearchFiltersMobile`. The following steps are required to add a mobile filter: - declare a prop with the same name that you added the filter config to `primaryFilters` or `secondaryFilters` - resolve the filters initial value with `initialValue` and `initialValues` methods -- use the `SelectSingleFilterPlain` and `SelectMultipleFilterPlain` components inside the - `
` element to render the filters +- use the `SelectSingleFilter` and `SelectMultipleFilter` components inside the + `
` element to render the filters. + +## Creating your own filter types + +If you are creating new filter types note that we are using two different types of components: popup +and plain. Popup components are rendered as primary dropdowns in the search view in `SearchFilters`. +Plain components are used with `SearchFiltersMobile` and `SearchFiltersPanel`. `SearchFiltersPanel` +opens sacondary filters in a distinct panel in order to fit additional filters to the desktop search +view. + +To make creating new filters easier, there are two generic components: `FilterPoup` and +`FilterPlain`. These components expect that you give form fields as child component. Check +`SelectMultipleFilter` to see how these components work. diff --git a/src/components/BookingDateRangeFilter/BookingDateRangeFilter.css b/src/components/BookingDateRangeFilter/BookingDateRangeFilter.css new file mode 100644 index 00000000..0268782e --- /dev/null +++ b/src/components/BookingDateRangeFilter/BookingDateRangeFilter.css @@ -0,0 +1,5 @@ +@import '../../marketplace.css'; + +.popupSize { + padding: 0 10px 7px 10px; +} diff --git a/src/components/BookingDateRangeFilter/BookingDateRangeFilter.example.js b/src/components/BookingDateRangeFilter/BookingDateRangeFilter.example.js new file mode 100644 index 00000000..ae112118 --- /dev/null +++ b/src/components/BookingDateRangeFilter/BookingDateRangeFilter.example.js @@ -0,0 +1,73 @@ +import React from 'react'; +import { withRouter } from 'react-router-dom'; +import { stringify, parse } from '../../util/urlHelpers'; +import { parseDateFromISO8601, stringifyDateToISO8601 } from '../../util/dates'; +import BookingDateRangeFilter from './BookingDateRangeFilter'; + +const URL_PARAM = 'dates'; + +const handleSubmit = (urlParam, values, history) => { + const hasDates = values && values.dates; + const { startDate, endDate } = hasDates ? values.dates : {}; + + const start = startDate ? stringifyDateToISO8601(startDate) : null; + const end = endDate ? stringifyDateToISO8601(endDate) : null; + + const queryParams = + start != null && end != null ? `?${stringify({ [urlParam]: [start, end].join(',') })}` : ''; + history.push(`${window.location.pathname}${queryParams}`); +}; + +const BookingDateRangeFilterWrapper = withRouter(props => { + const { history, location } = props; + + const params = parse(location.search); + const dates = params[URL_PARAM]; + 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 ( + { + console.log('Submit BookingDateRangeFilter with (unformatted) values:', values); + handleSubmit(urlParam, values, history); + }} + /> + ); +}); + +export const BookingDateRangeFilterPopup = { + component: BookingDateRangeFilterWrapper, + props: { + id: 'BookingDateRangeFilterPopupExample', + urlParam: URL_PARAM, + liveEdit: false, + showAsPopup: true, + contentPlacementOffset: -14, + // initialValues: handled inside wrapper + // onSubmit: handled inside wrapper + }, + group: 'misc', +}; + +export const BookingDateRangeFilterPlain = { + component: BookingDateRangeFilterWrapper, + props: { + id: 'BookingDateRangeFilterPlainExample', + urlParam: URL_PARAM, + liveEdit: true, + showAsPopup: false, + contentPlacementOffset: -14, + // initialValues: handled inside wrapper + // onSubmit: handled inside wrapper + }, + group: 'misc', +}; diff --git a/src/components/BookingDateRangeFilter/BookingDateRangeFilter.js b/src/components/BookingDateRangeFilter/BookingDateRangeFilter.js new file mode 100644 index 00000000..b1d39683 --- /dev/null +++ b/src/components/BookingDateRangeFilter/BookingDateRangeFilter.js @@ -0,0 +1,153 @@ +import React, { Component } from 'react'; +import { bool, func, number, object, string } from 'prop-types'; +import { injectIntl, intlShape } from 'react-intl'; + +import { FieldDateRangeController, FilterPopup, FilterPlain } from '../../components'; +import css from './BookingDateRangeFilter.css'; + +export class BookingDateRangeFilterComponent extends Component { + constructor(props) { + super(props); + + this.popupControllerRef = null; + this.plainControllerRef = null; + } + + render() { + const { + className, + rootClassName, + showAsPopup, + initialValues: initialValuesRaw, + id, + contentPlacementOffset, + onSubmit, + urlParam, + intl, + ...rest + } = this.props; + + const isSelected = !!initialValuesRaw && !!initialValuesRaw.dates; + const initialValues = isSelected ? initialValuesRaw : { dates: null }; + + const startDate = isSelected ? initialValues.dates.startDate : null; + const endDate = isSelected ? initialValues.dates.endDate : null; + + const format = { + month: 'short', + day: 'numeric', + }; + + const formattedStartDate = isSelected ? intl.formatDate(startDate, format) : null; + const formattedEndDate = isSelected ? intl.formatDate(endDate, format) : null; + + const labelForPlain = isSelected + ? intl.formatMessage( + { id: 'BookingDateRangeFilter.labelSelectedPlain' }, + { + dates: `${formattedStartDate} - ${formattedEndDate}`, + } + ) + : intl.formatMessage({ id: 'BookingDateRangeFilter.labelPlain' }); + + const labelForPopup = isSelected + ? intl.formatMessage( + { id: 'BookingDateRangeFilter.labelSelectedPopup' }, + { + dates: `${formattedStartDate} - ${formattedEndDate}`, + } + ) + : intl.formatMessage({ id: 'BookingDateRangeFilter.labelPopup' }); + + const onClearPopupMaybe = + this.popupControllerRef && this.popupControllerRef.onReset + ? { onClear: () => this.popupControllerRef.onReset(null, null) } + : {}; + + const onCancelPopupMaybe = + this.popupControllerRef && this.popupControllerRef.onReset + ? { onCancel: () => this.popupControllerRef.onReset(startDate, endDate) } + : {}; + + const onClearPlainMaybe = + this.plainControllerRef && this.plainControllerRef.onReset + ? { onClear: () => this.plainControllerRef.onReset(null, null) } + : {}; + + return showAsPopup ? ( + + { + this.popupControllerRef = node; + }} + /> + + ) : ( + + { + this.plainControllerRef = node; + }} + /> + + ); + } +} + +BookingDateRangeFilterComponent.defaultProps = { + rootClassName: null, + className: null, + showAsPopup: true, + liveEdit: false, + initialValues: null, + contentPlacementOffset: 0, +}; + +BookingDateRangeFilterComponent.propTypes = { + rootClassName: string, + className: string, + id: string.isRequired, + showAsPopup: bool, + liveEdit: bool, + urlParam: string.isRequired, + onSubmit: func.isRequired, + initialValues: object, + contentPlacementOffset: number, + + // form injectIntl + intl: intlShape.isRequired, +}; + +const BookingDateRangeFilter = injectIntl(BookingDateRangeFilterComponent); + +export default BookingDateRangeFilter; diff --git a/src/components/BookingDateRangeFilter/BookingDateRangeFilter.test.js b/src/components/BookingDateRangeFilter/BookingDateRangeFilter.test.js new file mode 100644 index 00000000..a12753ad --- /dev/null +++ b/src/components/BookingDateRangeFilter/BookingDateRangeFilter.test.js @@ -0,0 +1,43 @@ +import React from 'react'; + +// react-dates needs to be initialized before using any react-dates component +// Since this is currently only component using react-dates we can do it here +// https://github.com/airbnb/react-dates#initialize +import 'react-dates/initialize'; +import { renderShallow } from '../../util/test-helpers'; +import { fakeIntl } from '../../util/test-data'; +import { BookingDateRangeFilterComponent } from './BookingDateRangeFilter'; + +describe('BookingDateRangeFilter', () => { + it('matches popup snapshot', () => { + const tree = renderShallow( + null} + intl={fakeIntl} + /> + ); + expect(tree).toMatchSnapshot(); + }); + + it('matches plain snapshot', () => { + const tree = renderShallow( + null} + intl={fakeIntl} + /> + ); + expect(tree).toMatchSnapshot(); + }); +}); diff --git a/src/components/BookingDateRangeFilter/__snapshots__/BookingDateRangeFilter.test.js.snap b/src/components/BookingDateRangeFilter/__snapshots__/BookingDateRangeFilter.test.js.snap new file mode 100644 index 00000000..fa20fb44 --- /dev/null +++ b/src/components/BookingDateRangeFilter/__snapshots__/BookingDateRangeFilter.test.js.snap @@ -0,0 +1,54 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`BookingDateRangeFilter matches plain snapshot 1`] = ` + + + +`; + +exports[`BookingDateRangeFilter matches popup snapshot 1`] = ` + + + +`; diff --git a/src/components/FieldDateRangeController/DateRangeController.css b/src/components/FieldDateRangeController/DateRangeController.css new file mode 100644 index 00000000..6c55b669 --- /dev/null +++ b/src/components/FieldDateRangeController/DateRangeController.css @@ -0,0 +1,238 @@ +@import '../../marketplace.css'; + +:root { + /* + These variables are available in global scope through ":root" + element ( tag). Variables with the same names are going to + overwrite each other if CSS Properties' (PostCSS plugin) + configuration "preserve: true" is used - meaning that variables + are left to CSS bundle. We are planning to enable it in the future + since browsers support CSS Properties already. + */ + + --DateRangeController_selectionHeight: 36px; +} + +.inputRoot { + /* + Calendar component using react-dates has automatically padding so + negative margin to left and right is needed for element to fit on smaller screens. + */ + + margin: 0px -20px; + + @media (--viewportMedium) { + margin: 0; + } + + & :global(.CalendarMonthGrid) { + background-color: transparent; + } + + & :global(.DayPicker__horizontal) { + margin: 0 auto; + box-shadow: none; + background-color: transparent; + } + + & :global(.DayPickerNavigation__horizontal) { + position: relative; + width: 100%; + } + & :global(.DayPickerNavigation_button__horizontal) { + padding: 6px 9px; + top: 21px; + position: absolute; + cursor: pointer; + display: inline; + &:first-of-type { + left: 24px; + } + &:last-of-type { + right: 24px; + } + } + & :global(.DayPickerNavigation_button) { + color: var(--matterColor); + border: 0; + } + + & :global(.CalendarMonth), + & :global(.CalendarMonthGrid) { + background-color: transparent; + } + + & :global(.DayPicker_weekHeader) { + color: var(--matterColor); + top: 62px; + } + + & :global(.DayPicker_weekHeader_li) { + @apply --marketplaceH5FontStyles; + + margin-top: 0; + margin-bottom: 0; + + @media (--viewportMedium) { + margin-top: 0; + margin-bottom: 0; + } + } + + & :global(.DayPicker_weekHeader_li small) { + font-size: 100%; + } + + & :global(.CalendarMonth_caption) { + color: var(--matterColor); + @apply --marketplaceH3FontStyles; + margin: 1px 0 14px; + font-weight: 400; + line-height: 20px; + padding-top: 31px; + padding-bottom: 37px; + + &::first-letter { + text-transform: capitalize; + } + + @media (--viewportMedium) { + margin-top: 0; + margin-bottom: 0; + } + } + + & :global(.CalendarDay__default) { + border: 0; + padding: 0; + width: 100%; + height: 100%; + background: transparent; + } + + & :global(.CalendarDay) { + @apply --marketplaceH4FontStyles; + color: var(--matterColor); + border: 0; + margin-top: 0; + margin-bottom: 0; + + @media (--viewportMedium) { + margin-top: 0; + margin-bottom: 0; + } + } + + /* Add an underline for '.renderedDay' */ + & :global(.CalendarDay__today .renderedDay) { + display: flex; + justify-content: center; + align-items: center; + width: 100%; + height: var(--DateRangeController_selectionHeight); + background-image: url("data:image/svg+xml;utf8,"); + background-position: center 28px; + } + + & :global(.CalendarDay__today.CalendarDay__selected .renderedDay) { + background-image: url("data:image/svg+xml;utf8,"); + } + + & :global(.CalendarDay:hover .renderedDay) { + display: flex; + justify-content: center; + align-items: center; + width: 100%; + height: var(--DateRangeController_selectionHeight); + background-color: var(--matterColorNegative); + color: var(--matterColor); + } + + /* Remove default bg-color and use our extra span instead '.renderedDay' */ + & :global(.CalendarDay__hovered_span), + & :global(.CalendarDay__selected_span) { + background-image: transparent; + background-color: transparent; + } + & :global(.CalendarDay__hovered_span .renderedDay), + & :global(.CalendarDay__selected_span .renderedDay), + & :global(.CalendarDay__hovered_span:hover .renderedDay), + & :global(.CalendarDay__selected_span:hover .renderedDay) { + display: flex; + justify-content: center; + align-items: center; + width: 100%; + height: var(--DateRangeController_selectionHeight); + background-color: var(--successColor); + color: var(--matterColorLight); + } + + /* Remove default bg-color and use our extra span instead '.renderedDay' */ + & :global(.CalendarDay__selected_start) { + background-color: transparent; + background-image: none; + } + & :global(.CalendarDay__selected_start .renderedDay) { + display: flex; + justify-content: center; + align-items: center; + width: 100%; + height: var(--DateRangeController_selectionHeight); + background-color: var(--successColor); + color: var(--matterColorLight); + border-top-left-radius: calc(var(--DateRangeController_selectionHeight) / 2); + border-bottom-left-radius: calc(var(--DateRangeController_selectionHeight) / 2); + } + + /* Remove default bg-color and use our extra span instead '.renderedDay' */ + & :global(.CalendarDay__selected_end) { + background-color: transparent; + } + & :global(.CalendarDay__selected_end .renderedDay) { + display: flex; + justify-content: center; + align-items: center; + width: 100%; + height: var(--DateRangeController_selectionHeight); + background-color: var(--successColor); + color: var(--matterColorLight); + border-top-right-radius: calc(var(--DateRangeController_selectionHeight) / 2); + border-bottom-right-radius: calc(var(--DateRangeController_selectionHeight) / 2); + } + & :global(.CalendarDay:hover.CalendarDay__selected_start .renderedDay), + & :global(.CalendarDay:hover.CalendarDay__selected_span .renderedDay), + & :global(.CalendarDay:hover.CalendarDay__selected_end .renderedDay) { + display: flex; + justify-content: center; + align-items: center; + width: 100%; + height: var(--DateRangeController_selectionHeight); + background-color: var(--successColor); + color: var(--matterColorLight); + } + + & :global(.CalendarDay__selected_span .renderedDay) { + display: flex; + justify-content: center; + align-items: center; + width: 100%; + height: var(--DateRangeController_selectionHeight); + background-color: var(--successColor); + color: var(--matterColorLight); + transition: all 0.2s ease-out; + } + + /* Remove default bg-color and use our extra span instead '.renderedDay' */ + & :global(.CalendarDay__blocked_out_of_range .renderedDay), + & :global(.CalendarDay__blocked_out_of_range:hover .renderedDay) { + background-color: transparent; + color: var(--matterColorAnti); + text-decoration: line-through; + border: 0; + } +} + +.arrowIcon { + stroke: var(--marketplaceColor); + fill: var(--marketplaceColor); +} diff --git a/src/components/FieldDateRangeController/DateRangeController.example.js b/src/components/FieldDateRangeController/DateRangeController.example.js new file mode 100644 index 00000000..9c6c728e --- /dev/null +++ b/src/components/FieldDateRangeController/DateRangeController.example.js @@ -0,0 +1,7 @@ +import DateRangeController from './DateRangeController'; + +export const DateRangeControllerExample = { + component: DateRangeController, + props: {}, + group: 'custom inputs', +}; diff --git a/src/components/FieldDateRangeController/DateRangeController.js b/src/components/FieldDateRangeController/DateRangeController.js new file mode 100644 index 00000000..92e86d73 --- /dev/null +++ b/src/components/FieldDateRangeController/DateRangeController.js @@ -0,0 +1,190 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { + DayPickerRangeController, + isInclusivelyAfterDay, + isInclusivelyBeforeDay, +} from 'react-dates'; +import classNames from 'classnames'; +import moment from 'moment'; +import { START_DATE } from '../../util/dates'; +import config from '../../config'; + +import { IconArrowHead } from '../../components'; +import css from './DateRangeController.css'; + +export const HORIZONTAL_ORIENTATION = 'horizontal'; +export const ANCHOR_LEFT = 'left'; + +// IconArrowHead component might not be defined if exposed directly to the file. +// This component is called before IconArrowHead component in components/index.js +const PrevIcon = props => ( + +); +const NextIcon = props => ( + +); + +const defaultProps = { + startDateOffset: undefined, + endDateOffset: undefined, + + // calendar presentation and interaction related props + + orientation: HORIZONTAL_ORIENTATION, + verticalHeight: undefined, + withPortal: false, + isRTL: false, + initialVisibleMonth: null, + firstDayOfWeek: config.i18n.firstDayOfWeek, + numberOfMonths: 1, + daySize: 38, + keepOpenOnDateSelect: false, + renderCalendarInfo: null, + hideKeyboardShortcutsPanel: true, + + // navigation related props + navPrev: , + navNext: , + onPrevMonthClick() {}, + onNextMonthClick() {}, + transitionDuration: 200, // milliseconds between next month changes etc. + + renderCalendarDay: undefined, // If undefined, renders react-dates/lib/components/CalendarDay + // day presentation and interaction related props + renderDayContents: day => { + return {day.format('D')}; + }, + minimumNights: 1, + enableOutsideDays: false, + isDayBlocked: () => false, + + // outside range -><- today ... today+available days -1 -><- outside range + isOutsideRange: day => { + const endOfRange = config.dayCountAvailableForBooking - 1; + return ( + !isInclusivelyAfterDay(day, moment()) || + !isInclusivelyBeforeDay(day, moment().add(endOfRange, 'days')) + ); + }, + isDayHighlighted: () => {}, + + // Internationalization props + // Multilocale support can be achieved with displayFormat like moment.localeData.longDateFormat('L') + // https://momentjs.com/ + // displayFormat: 'ddd, MMM D', + monthFormat: 'MMMM YYYY', + weekDayFormat: 'dd', + phrases: {}, // Add overwrites to default phrases used by react-dates +}; + +class DateRangeController extends Component { + constructor(props) { + super(props); + + this.state = { + startDate: props.value && props.value.startDate ? moment(props.value.startDate) : null, + endDate: props.value && props.value.endDate ? moment(props.value.endDate) : null, + focusedInput: START_DATE, + }; + + this.onDatesChange = this.onDatesChange.bind(this); + this.onFocusChange = this.onFocusChange.bind(this); + this.onReset = this.onReset.bind(this); + } + + onDatesChange(values) { + const { startDate, endDate } = values; + + const start = startDate ? startDate.toDate() : null; + const end = endDate ? endDate.toDate() : null; + + this.setState({ startDate, endDate }); + + if (startDate && endDate) { + this.props.onChange({ startDate: start, endDate: end }); + } + } + + onFocusChange(focusedInput) { + this.setState({ + // Force the focusedInput to always be truthy so that dates are always selectable + focusedInput: !focusedInput ? START_DATE : focusedInput, + }); + } + + onReset(startDate, endDate) { + if (startDate && endDate) { + this.setState({ + startDate: moment(startDate), + endDate: moment(endDate), + focusedInput: START_DATE, + }); + } else { + this.setState({ + startDate: null, + endDate: null, + focusedInput: START_DATE, + }); + } + } + + render() { + // Removing Final Form field props: name, value, onChange, onFocus, meta, children, render + const { + rootClassName, + className, + name, + value, + onChange, + onFocus, + meta, + children, + render, + ...controllerProps + } = this.props; + + const classes = classNames(rootClassName || css.inputRoot, className); + + const startDateFromState = this.state.startDate; + const endDateFromState = this.state.endDate; + + const startDateFromForm = value && value.startDate ? moment(value.startDate) : null; + const endDateFromForm = value && value.endDate ? moment(value.endDate) : null; + + const isSelected = startDateFromState && endDateFromState; + + // Value given by Final Form reflects url params and is valid if both dates are set. + // If only one date is selected state should be used to get the correct date. + const startDate = isSelected ? startDateFromForm : startDateFromState; + const endDate = isSelected ? endDateFromForm : endDateFromState; + + return ( +
+ +
+ ); + } +} + +DateRangeController.defaultProps = { + rootClassName: null, + className: null, + ...defaultProps, +}; + +const { string } = PropTypes; + +DateRangeController.propTypes = { + rootClassName: string, + className: string, +}; + +export default DateRangeController; diff --git a/src/components/FieldDateRangeController/FieldDateRangeController.example.js b/src/components/FieldDateRangeController/FieldDateRangeController.example.js new file mode 100644 index 00000000..a4a76209 --- /dev/null +++ b/src/components/FieldDateRangeController/FieldDateRangeController.example.js @@ -0,0 +1,40 @@ +import React from 'react'; +import { Form as FinalForm } from 'react-final-form'; + +import FieldDateRangeController from './FieldDateRangeController'; + +const FormComponent = props => ( + { + const { style, handleSubmit, onChange, dirty } = formRenderProps; + + const handleChange = dirty ? onChange : () => null; + return ( +
{ + e.preventDefault(); + handleSubmit(e); + }} + > + + + ); + }} + /> +); + +export const DateRangeControllerExample = { + component: FormComponent, + props: { + onChange: values => { + if (values) { + const { startDate, endDate } = values; + console.log('Changed to: ', startDate, endDate); + } + }, + onSubmit: () => null, + }, + group: 'custom inputs', +}; diff --git a/src/components/FieldDateRangeController/FieldDateRangeController.js b/src/components/FieldDateRangeController/FieldDateRangeController.js new file mode 100644 index 00000000..e0652517 --- /dev/null +++ b/src/components/FieldDateRangeController/FieldDateRangeController.js @@ -0,0 +1,25 @@ +import React from 'react'; +import { string } from 'prop-types'; +import { Field } from 'react-final-form'; +import DateRangeController from './DateRangeController'; + +const component = props => { + const { input, controllerRef, ...rest } = props; + return ; +}; + +const FieldDateRangeController = props => { + return ; +}; + +FieldDateRangeController.defaultProps = { + rootClassName: null, + className: null, +}; + +FieldDateRangeController.propTypes = { + rootClassName: string, + className: string, +}; + +export default FieldDateRangeController; diff --git a/src/components/FieldDateRangeInput/DateRangeInput.css b/src/components/FieldDateRangeInput/DateRangeInput.css index d3807836..2a6a6233 100644 --- a/src/components/FieldDateRangeInput/DateRangeInput.css +++ b/src/components/FieldDateRangeInput/DateRangeInput.css @@ -154,7 +154,7 @@ width: 100%; height: var(--DateRangeInput_selectionHeight); background-image: url("data:image/svg+xml;utf8,"); - background-position: center 28px; + background-position: center 34px; } /* Remove default bg-color and use our extra span instead '.renderedDay' */ diff --git a/src/components/SelectMultipleFilterPlain/SelectMultipleFilterPlain.css b/src/components/FilterPlain/FilterPlain.css similarity index 85% rename from src/components/SelectMultipleFilterPlain/SelectMultipleFilterPlain.css rename to src/components/FilterPlain/FilterPlain.css index ae0ebbc3..89d8888c 100644 --- a/src/components/SelectMultipleFilterPlain/SelectMultipleFilterPlain.css +++ b/src/components/FilterPlain/FilterPlain.css @@ -1,6 +1,7 @@ @import '../../marketplace.css'; .root { + position: relative; padding-top: 24px; padding-bottom: 17px; border-bottom: 1px solid var(--matterColorNegative); @@ -33,20 +34,6 @@ cursor: pointer; } -.optionsContainerOpen { - height: auto; - padding-left: 20px; - padding-bottom: 30px; -} - -.optionsContainerClosed { - height: 0; - overflow: hidden; -} - -.columnLayout { -} - .clearButton { @apply --marketplaceH5FontStyles; font-weight: var(--fontWeightMedium); @@ -68,3 +55,12 @@ color: var(--matterColor); } } + +.plain { + width: 100%; + display: none; +} + +.isOpen { + display: block; +} diff --git a/src/components/FilterPlain/FilterPlain.example.js b/src/components/FilterPlain/FilterPlain.example.js new file mode 100644 index 00000000..eb82709d --- /dev/null +++ b/src/components/FilterPlain/FilterPlain.example.js @@ -0,0 +1,25 @@ +import React from 'react'; +import { FieldTextInput } from '../../components'; +import FilterPlain from './FilterPlain'; + +const id = 'FilterPlainExample'; +const field = ; + +export const FilterPlainExample = { + component: FilterPlain, + props: { + id, + liveEdit: true, + showAsPopup: false, + isSelected: false, + urlParam: 'example', + initialValues: {}, + contentPlacementOffset: -14, + onSubmit: (urlParam, values) => { + console.log(`onSubmit with urlParam: ${urlParam} and values: ${JSON.stringify(values)}`); + }, + label: 'Example label', + children: field, + }, + group: 'misc', +}; diff --git a/src/components/FilterPlain/FilterPlain.js b/src/components/FilterPlain/FilterPlain.js new file mode 100644 index 00000000..da36e9a4 --- /dev/null +++ b/src/components/FilterPlain/FilterPlain.js @@ -0,0 +1,109 @@ +import React, { Component } from 'react'; +import { bool, func, node, string } from 'prop-types'; +import classNames from 'classnames'; +import { injectIntl, intlShape, FormattedMessage } from 'react-intl'; + +import { FilterForm } from '../../forms'; +import css from './FilterPlain.css'; + +class FilterPlainComponent extends Component { + constructor(props) { + super(props); + this.state = { isOpen: true }; + + this.handleChange = this.handleChange.bind(this); + this.handleClear = this.handleClear.bind(this); + this.toggleIsOpen = this.toggleIsOpen.bind(this); + } + + handleChange(values) { + const { onSubmit, urlParam } = this.props; + onSubmit(urlParam, values); + } + + handleClear() { + const { onSubmit, onClear, urlParam } = this.props; + + if (onClear) { + onClear(); + } + + onSubmit(urlParam, null); + } + + toggleIsOpen() { + this.setState(prevState => ({ isOpen: !prevState.isOpen })); + } + + render() { + const { + rootClassName, + className, + plainClassName, + id, + label, + isSelected, + children, + initialValues, + contentPlacementOffset, + } = this.props; + const classes = classNames(rootClassName || css.root, className); + + const labelClass = isSelected ? css.filterLabelSelected : css.filterLabel; + + return ( +
+
+ + +
+
{ + this.filterContent = node; + }} + > + + {children} + +
+
+ ); + } +} + +FilterPlainComponent.defaultProps = { + rootClassName: null, + className: null, + plainClassName: null, + initialValues: null, +}; + +FilterPlainComponent.propTypes = { + rootClassName: string, + className: string, + plainClassName: string, + id: string.isRequired, + onSubmit: func.isRequired, + label: node.isRequired, + isSelected: bool.isRequired, + children: node.isRequired, + + // form injectIntl + intl: intlShape.isRequired, +}; + +const FilterPlain = injectIntl(FilterPlainComponent); + +export default FilterPlain; diff --git a/src/components/FilterPopup/FilterPopup.css b/src/components/FilterPopup/FilterPopup.css new file mode 100644 index 00000000..f3d73f47 --- /dev/null +++ b/src/components/FilterPopup/FilterPopup.css @@ -0,0 +1,77 @@ +@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); + } +} + +.popup { + /* By default hide the content */ + display: block; + visibility: hidden; + opacity: 0; + pointer-events: none; + + /* Position */ + position: absolute; + z-index: var(--zIndexPopup); + + /* Layout */ + min-width: 300px; + margin-top: 7px; + background-color: var(--matterColorLight); + + /* Borders */ + border-top: 1px solid var(--matterColorNegative); + box-shadow: var(--boxShadowPopup); + border-radius: 4px; + transition: var(--transitionStyleButton); +} + +.popupSize { + padding: 15px 30px 17px 30px; +} + +.isOpen { + display: block; + visibility: visible; + opacity: 1; + pointer-events: auto; +} diff --git a/src/components/FilterPopup/FilterPopup.example.js b/src/components/FilterPopup/FilterPopup.example.js new file mode 100644 index 00000000..7c24ab37 --- /dev/null +++ b/src/components/FilterPopup/FilterPopup.example.js @@ -0,0 +1,25 @@ +import React from 'react'; +import { FieldTextInput } from '../../components'; +import FilterPopup from './FilterPopup'; + +const id = 'FilterPopupExample'; +const field = ; + +export const FilterPopupExample = { + component: FilterPopup, + props: { + id, + liveEdit: false, + showAsPopup: true, + urlParam: 'example', + initialValues: {}, + isSelected: false, + contentPlacementOffset: -14, + onSubmit: (urlParam, values) => { + console.log(`onSubmit with urlParam: ${urlParam} and values: ${JSON.stringify(values)}`); + }, + label: 'Example label', + children: field, + }, + group: 'misc', +}; diff --git a/src/components/FilterPopup/FilterPopup.js b/src/components/FilterPopup/FilterPopup.js new file mode 100644 index 00000000..dd076349 --- /dev/null +++ b/src/components/FilterPopup/FilterPopup.js @@ -0,0 +1,186 @@ +import React, { Component } from 'react'; +import { bool, func, node, number, object, string } from 'prop-types'; +import classNames from 'classnames'; +import { injectIntl, intlShape } from 'react-intl'; + +import { OutsideClickHandler } from '../../components'; +import { FilterForm } from '../../forms'; +import css from './FilterPopup.css'; + +const KEY_CODE_ESCAPE = 27; + +class FilterPopup extends Component { + constructor(props) { + super(props); + + this.state = { isOpen: false }; + this.filter = null; + this.filterContent = null; + + this.handleSubmit = this.handleSubmit.bind(this); + this.handleClear = this.handleClear.bind(this); + this.handleCancel = this.handleCancel.bind(this); + this.handleBlur = this.handleBlur.bind(this); + this.handleKeyDown = this.handleKeyDown.bind(this); + this.toggleOpen = this.toggleOpen.bind(this); + this.positionStyleForContent = this.positionStyleForContent.bind(this); + } + + handleSubmit(values) { + const { onSubmit, urlParam } = this.props; + this.setState({ isOpen: false }); + onSubmit(urlParam, values); + } + + handleClear() { + const { onSubmit, onClear, urlParam } = this.props; + this.setState({ isOpen: false }); + + if (onClear) { + onClear(); + } + + onSubmit(urlParam, null); + } + + handleCancel() { + const { onSubmit, onCancel, initialValues, urlParam } = this.props; + this.setState({ isOpen: false }); + + if (onCancel) { + onCancel(); + } + + onSubmit(urlParam, initialValues); + } + + handleBlur() { + this.setState({ isOpen: false }); + } + + handleKeyDown(e) { + // Gather all escape presses to close menu + if (e.keyCode === KEY_CODE_ESCAPE) { + this.toggleOpen(false); + } + } + + toggleOpen(enforcedState) { + if (enforcedState) { + this.setState({ isOpen: enforcedState }); + } else { + this.setState(prevState => ({ isOpen: !prevState.isOpen })); + } + } + + 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, + popupClassName, + id, + label, + isSelected, + children, + initialValues, + contentPlacementOffset, + } = this.props; + + const classes = classNames(rootClassName || css.root, className); + const popupClasses = classNames(css.popup, { [css.isOpen]: this.state.isOpen }); + const popupSizeClasses = popupClassName || css.popupSize; + const labelStyles = isSelected ? css.labelSelected : css.label; + const contentStyle = this.positionStyleForContent(); + + return ( + +
{ + this.filter = node; + }} + > + +
{ + this.filterContent = node; + }} + style={contentStyle} + > + {this.state.isOpen ? ( + + {children} + + ) : null} +
+
+
+ ); + } +} + +FilterPopup.defaultProps = { + rootClassName: null, + className: null, + popupClassName: null, + initialValues: null, + contentPlacementOffset: 0, + liveEdit: false, + label: null, +}; + +FilterPopup.propTypes = { + rootClassName: string, + className: string, + popupClassName: string, + id: string.isRequired, + urlParam: string.isRequired, + onSubmit: func.isRequired, + initialValues: object, + contentPlacementOffset: number, + label: string.isRequired, + isSelected: bool.isRequired, + children: node.isRequired, + + // form injectIntl + intl: intlShape.isRequired, +}; + +export default injectIntl(FilterPopup); diff --git a/src/components/OutsideClickHandler/OutsideClickHandler.css b/src/components/OutsideClickHandler/OutsideClickHandler.css new file mode 100644 index 00000000..9b0dbeb1 --- /dev/null +++ b/src/components/OutsideClickHandler/OutsideClickHandler.css @@ -0,0 +1,5 @@ +@import '../../marketplace.css'; + +.root { + display: inline-block; +} diff --git a/src/components/OutsideClickHandler/OutsideClickHandler.example.js b/src/components/OutsideClickHandler/OutsideClickHandler.example.js new file mode 100644 index 00000000..aef9581d --- /dev/null +++ b/src/components/OutsideClickHandler/OutsideClickHandler.example.js @@ -0,0 +1,39 @@ +import React, { Component } from 'react'; +import OutsideClickHandler from './OutsideClickHandler'; + +const childStyle = { + padding: '16px', + background: '#e7e7e7', +}; + +class OutsideClickHandlerWrapper extends Component { + constructor(props) { + super(props); + + this.state = { + message: 'This is OutsideClickHandler example', + }; + + this.handleClick = this.handleClick.bind(this); + } + + handleClick() { + this.setState({ message: 'You clicked outside!' }); + } + + render() { + return ( + +
+

{this.state.message}

+
+
+ ); + } +} + +export const FilterPopupExample = { + component: OutsideClickHandlerWrapper, + props: {}, + group: 'misc', +}; diff --git a/src/components/OutsideClickHandler/OutsideClickHandler.js b/src/components/OutsideClickHandler/OutsideClickHandler.js new file mode 100644 index 00000000..e424f434 --- /dev/null +++ b/src/components/OutsideClickHandler/OutsideClickHandler.js @@ -0,0 +1,50 @@ +import React, { Component } from 'react'; +import { func, node, string } from 'prop-types'; +import classNames from 'classnames'; + +import css from './OutsideClickHandler.css'; + +export default class OutsideClickHandler extends Component { + constructor(props) { + super(props); + + this.handleClick = this.handleClick.bind(this); + } + + componentDidMount() { + document.addEventListener('mousedown', this.handleClick, false); + } + + componentWillUnmount() { + document.removeEventListener('mousedown', this.handleClick, false); + } + + handleClick(event) { + if (!this.node.contains(event.target)) { + this.props.onOutsideClick(); + } + } + + render() { + const { rootClassName, className, children } = this.props; + const classes = classNames(rootClassName || css.root, className); + + return ( +
(this.node = node)}> + {children} +
+ ); + } +} + +OutsideClickHandler.defaultProps = { + rootClassName: null, + className: null, +}; + +OutsideClickHandler.propTypes = { + rootClassName: string, + className: string, + onOutsideClick: func.isRequired, + children: node.isRequired, +}; diff --git a/src/components/SearchFilters/SearchFilters.js b/src/components/SearchFilters/SearchFilters.js index b49bc269..a7315962 100644 --- a/src/components/SearchFilters/SearchFilters.js +++ b/src/components/SearchFilters/SearchFilters.js @@ -6,8 +6,14 @@ import classNames from 'classnames'; import { withRouter } from 'react-router-dom'; import omit from 'lodash/omit'; -import { SelectSingleFilter, SelectMultipleFilter, PriceFilter } from '../../components'; +import { + BookingDateRangeFilter, + SelectSingleFilter, + SelectMultipleFilter, + PriceFilter, +} from '../../components'; import routeConfiguration from '../../routeConfiguration'; +import { parseDateFromISO8601, stringifyDateToISO8601 } from '../../util/dates'; import { createResourceLocatorString } from '../../util/routes'; import { propTypes } from '../../util/types'; import css from './SearchFilters.css'; @@ -38,6 +44,20 @@ const initialPriceRangeValue = (queryParams, paramName) => { : 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, @@ -49,6 +69,7 @@ const SearchFiltersComponent = props => { categoryFilter, amenitiesFilter, priceFilter, + dateRangeFilter, isSearchFiltersPanelOpen, toggleSearchFiltersPanel, searchFiltersPanelSelectedCount, @@ -79,6 +100,10 @@ const SearchFiltersComponent = props => { ? initialPriceRangeValue(urlQueryParams, priceFilter.paramName) : null; + const initialDateRange = dateRangeFilter + ? initialDateRangeValue(urlQueryParams, dateRangeFilter.paramName) + : null; + const handleSelectOptions = (urlParam, options) => { const queryParams = options && options.length > 0 @@ -108,11 +133,26 @@ const SearchFiltersComponent = props => { history.push(createResourceLocatorString('SearchPage', routeConfiguration(), {}, queryParams)); }; + const handleDateRange = (urlParam, dateRange) => { + const hasDates = dateRange && dateRange.dates; + const { startDate, endDate } = hasDates ? dateRange.dates : {}; + + const start = startDate ? stringifyDateToISO8601(startDate) : null; + const end = endDate ? stringifyDateToISO8601(endDate) : null; + + const queryParams = + start != null && end != null + ? { ...urlQueryParams, [urlParam]: `${start},${end}` } + : omit(urlQueryParams, urlParam); + history.push(createResourceLocatorString('SearchPage', routeConfiguration(), {}, queryParams)); + }; + const categoryFilterElement = categoryFilter ? ( { name="amenities" urlParam={amenitiesFilter.paramName} label={amenitiesLabel} - onSelect={handleSelectOptions} + onSubmit={handleSelectOptions} + showAsPopup options={amenitiesFilter.options} initialValues={initialAmenities} contentPlacementOffset={FILTER_DROPDOWN_OFFSET} @@ -144,6 +185,18 @@ const SearchFiltersComponent = props => { /> ) : null; + const dateRangeFilterElement = + dateRangeFilter && dateRangeFilter.config.active ? ( + + ) : null; + const toggleSearchFiltersPanelButtonClasses = isSearchFiltersPanelOpen || searchFiltersPanelSelectedCount > 0 ? css.searchFiltersPanelOpen @@ -167,6 +220,7 @@ const SearchFiltersComponent = props => { {categoryFilterElement} {amenitiesFilterElement} {priceFilterElement} + {dateRangeFilterElement} {toggleSearchFiltersPanelButton}
@@ -200,6 +254,8 @@ SearchFiltersComponent.defaultProps = { searchingInProgress: false, categoryFilter: null, amenitiesFilter: null, + priceFilter: null, + dateRangeFilter: null, isSearchFiltersPanelOpen: false, toggleSearchFiltersPanel: null, searchFiltersPanelSelectedCount: 0, @@ -216,6 +272,7 @@ SearchFiltersComponent.propTypes = { categoriesFilter: propTypes.filterConfig, amenitiesFilter: propTypes.filterConfig, priceFilter: propTypes.filterConfig, + dateRangeFilter: propTypes.filterConfig, isSearchFiltersPanelOpen: bool, toggleSearchFiltersPanel: func, searchFiltersPanelSelectedCount: number, diff --git a/src/components/SearchFiltersMobile/SearchFiltersMobile.js b/src/components/SearchFiltersMobile/SearchFiltersMobile.js index a1cfb102..383c6419 100644 --- a/src/components/SearchFiltersMobile/SearchFiltersMobile.js +++ b/src/components/SearchFiltersMobile/SearchFiltersMobile.js @@ -6,14 +6,16 @@ import { withRouter } from 'react-router-dom'; import omit from 'lodash/omit'; import routeConfiguration from '../../routeConfiguration'; +import { parseDateFromISO8601, stringifyDateToISO8601 } from '../../util/dates'; import { createResourceLocatorString } from '../../util/routes'; import { SecondaryButton, ModalInMobile, Button, PriceFilter, - SelectSingleFilterPlain, - SelectMultipleFilterPlain, + SelectSingleFilter, + SelectMultipleFilter, + BookingDateRangeFilter, } from '../../components'; import { propTypes } from '../../util/types'; import css from './SearchFiltersMobile.css'; @@ -32,9 +34,11 @@ class SearchFiltersMobileComponent extends Component { this.handleSelectSingle = this.handleSelectSingle.bind(this); this.handleSelectMultiple = this.handleSelectMultiple.bind(this); this.handlePrice = this.handlePrice.bind(this); + this.handleDateRange = this.handleDateRange.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 @@ -100,6 +104,21 @@ class SearchFiltersMobileComponent extends Component { history.push(createResourceLocatorString('SearchPage', routeConfiguration(), {}, queryParams)); } + handleDateRange(urlParam, dateRange) { + const { urlQueryParams, history } = this.props; + const hasDates = dateRange && dateRange.dates; + const { startDate, endDate } = hasDates ? dateRange.dates : {}; + + const start = startDate ? stringifyDateToISO8601(startDate) : null; + const end = endDate ? stringifyDateToISO8601(endDate) : null; + + const queryParams = + start != null && end != null + ? { ...urlQueryParams, [urlParam]: `${start},${end}` } + : omit(urlQueryParams, urlParam); + history.push(createResourceLocatorString('SearchPage', routeConfiguration(), {}, queryParams)); + } + // Reset all filter query parameters resetAll(e) { const { urlQueryParams, history, filterParamNames } = this.props; @@ -137,6 +156,21 @@ class SearchFiltersMobileComponent extends Component { : 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, @@ -151,6 +185,7 @@ class SearchFiltersMobileComponent extends Component { categoryFilter, amenitiesFilter, priceFilter, + dateRangeFilter, intl, } = this.props; @@ -186,10 +221,11 @@ class SearchFiltersMobileComponent extends Component { const initialCategory = categoryFilter ? this.initialValue(categoryFilter.paramName) : null; const categoryFilterElement = categoryFilter ? ( - @@ -225,6 +262,20 @@ class SearchFiltersMobileComponent extends Component { /> ) : null; + const initialDateRange = this.initialDateRangeValue(dateRangeFilter.paramName); + + const dateRangeFilterElement = + dateRangeFilter && dateRangeFilter.config.active ? ( + + ) : null; + return (
@@ -253,11 +304,15 @@ class SearchFiltersMobileComponent extends Component {
-
- {categoryFilterElement} - {amenitiesFilterElement} - {priceFilterElement} -
+ {this.state.isFiltersOpenOnMobile ? ( +
+ {categoryFilterElement} + {amenitiesFilterElement} + {priceFilterElement} + {dateRangeFilterElement} +
+ ) : null} +
- { - this.filterContent = node; - }} - style={contentStyle} /> -
+ + ) : ( + + + ); } } @@ -157,7 +147,7 @@ SelectMultipleFilter.propTypes = { name: string.isRequired, urlParam: string.isRequired, label: string.isRequired, - onSelect: func.isRequired, + onSubmit: func.isRequired, options: array.isRequired, initialValues: arrayOf(string), contentPlacementOffset: number, diff --git a/src/components/SelectMultipleFilterPlain/SelectMultipleFilterPlain.example.js b/src/components/SelectMultipleFilterPlain/SelectMultipleFilterPlain.example.js deleted file mode 100644 index 1c38bd08..00000000 --- a/src/components/SelectMultipleFilterPlain/SelectMultipleFilterPlain.example.js +++ /dev/null @@ -1,73 +0,0 @@ -import React from 'react'; -import { withRouter } from 'react-router-dom'; -import SelectMultipleFilterPlain from './SelectMultipleFilterPlain'; -import { stringify, parse } from '../../util/urlHelpers'; - -const URL_PARAM = 'pub_amenities'; - -const options = [ - { - key: 'towels', - label: 'Towels', - }, - { - key: 'bathroom', - label: 'Bathroom', - }, - { - key: 'swimming_pool', - label: 'Swimming pool', - }, - { - key: 'own_drinks', - label: 'Own drinks allowed', - }, - { - key: 'jacuzzi', - label: 'Jacuzzi', - }, - { - key: 'audiovisual_entertainment', - label: 'Audiovisual entertainment', - }, - { - key: 'barbeque', - label: 'Barbeque', - }, - { - key: 'own_food_allowed', - label: 'Own food allowed', - }, -]; - -const handleSelect = (urlParam, values, history) => { - console.log(`handle select`, values); - const queryParams = values ? `?${stringify({ [urlParam]: values.join(',') })}` : ''; - history.push(`${window.location.pathname}${queryParams}`); -}; - -const AmenitiesFilterComponent = props => { - const { history, location } = props; - - const params = parse(location.search); - const amenities = params[URL_PARAM]; - const initialValues = !!amenities ? amenities.split(',') : []; - - return ( - handleSelect(urlParam, values, history)} - initialValues={initialValues} - /> - ); -}; - -export const AmenitiesFilter = { - component: withRouter(AmenitiesFilterComponent), - props: {}, - group: 'misc', -}; diff --git a/src/components/SelectMultipleFilterPlain/SelectMultipleFilterPlain.js b/src/components/SelectMultipleFilterPlain/SelectMultipleFilterPlain.js deleted file mode 100644 index fe5e9a75..00000000 --- a/src/components/SelectMultipleFilterPlain/SelectMultipleFilterPlain.js +++ /dev/null @@ -1,119 +0,0 @@ -import React, { Component } from 'react'; -import { array, bool, func, string } from 'prop-types'; -import classNames from 'classnames'; -import { injectIntl, intlShape, FormattedMessage } from 'react-intl'; - -import { SelectMultipleFilterPlainForm } from '../../forms'; - -import css from './SelectMultipleFilterPlain.css'; - -class SelectMultipleFilterPlainComponent extends Component { - constructor(props) { - super(props); - this.state = { isOpen: true }; - - this.handleSelect = this.handleSelect.bind(this); - this.handleClear = this.handleClear.bind(this); - this.toggleIsOpen = this.toggleIsOpen.bind(this); - } - - handleSelect(values) { - const { urlParam, name, onSelect } = this.props; - const paramValues = values[name]; - onSelect(urlParam, paramValues); - } - - handleClear() { - const { urlParam, onSelect } = this.props; - onSelect(urlParam, null); - } - - toggleIsOpen() { - this.setState(prevState => ({ isOpen: !prevState.isOpen })); - } - - render() { - const { - rootClassName, - className, - id, - name, - label, - options, - initialValues, - intl, - twoColumns, - } = this.props; - - const classes = classNames(rootClassName || css.root, className); - - const hasInitialValues = initialValues.length > 0; - const labelClass = hasInitialValues ? css.filterLabelSelected : css.filterLabel; - - const labelText = hasInitialValues - ? intl.formatMessage( - { id: 'SelectMultipleFilterPlainForm.labelSelected' }, - { labelText: label, count: initialValues.length } - ) - : label; - - const optionsContainerClass = classNames({ - [css.optionsContainerOpen]: this.state.isOpen, - [css.optionsContainerClosed]: !this.state.isOpen, - [css.columnLayout]: twoColumns, - }); - - const namedInitialValues = { [name]: initialValues }; - - return ( -
-
- - -
- -
- ); - } -} - -SelectMultipleFilterPlainComponent.defaultProps = { - rootClassName: null, - className: null, - initialValues: [], - twoColumns: false, -}; - -SelectMultipleFilterPlainComponent.propTypes = { - rootClassName: string, - className: string, - id: string.isRequired, - name: string.isRequired, - urlParam: string.isRequired, - label: string.isRequired, - onSelect: func.isRequired, - options: array.isRequired, - initialValues: array, - twoColumns: bool, - - // from injectIntl - intl: intlShape.isRequired, -}; - -const SelectMultipleFilterPlain = injectIntl(SelectMultipleFilterPlainComponent); - -export default SelectMultipleFilterPlain; diff --git a/src/components/SelectSingleFilter/SelectSingleFilter.js b/src/components/SelectSingleFilter/SelectSingleFilter.js index bf9d2faf..ac03f503 100644 --- a/src/components/SelectSingleFilter/SelectSingleFilter.js +++ b/src/components/SelectSingleFilter/SelectSingleFilter.js @@ -1,111 +1,23 @@ -import React, { Component } from 'react'; -import { string, func, arrayOf, shape, number } from 'prop-types'; -import { FormattedMessage } from 'react-intl'; -import classNames from 'classnames'; +import React from 'react'; +import { bool } from 'prop-types'; +import SelectSingleFilterPlain from './SelectSingleFilterPlain'; +import SelectSingleFilterPopup from './SelectSingleFilterPopup'; -import { Menu, MenuContent, MenuItem, MenuLabel } from '../../components'; -import css from './SelectSingleFilter.css'; - -const optionLabel = (options, key) => { - const option = options.find(o => o.key === key); - return option ? option.label : key; +const SelectSingleFilter = props => { + const { showAsPopup, ...rest } = props; + return showAsPopup ? ( + + ) : ( + + ); }; -class SelectSingleFilter extends Component { - constructor(props) { - super(props); - - this.state = { isOpen: false }; - this.onToggleActive = this.onToggleActive.bind(this); - this.selectOption = this.selectOption.bind(this); - } - - onToggleActive(isOpen) { - this.setState({ isOpen: isOpen }); - } - - selectOption(urlParam, option) { - this.setState({ isOpen: false }); - this.props.onSelect(urlParam, option); - } - - render() { - const { - rootClassName, - className, - urlParam, - label, - options, - initialValue, - contentPlacementOffset, - } = this.props; - - // resolve menu label text and class - const menuLabel = initialValue ? optionLabel(options, initialValue) : label; - const menuLabelClass = initialValue ? css.menuLabelSelected : css.menuLabel; - - const classes = classNames(rootClassName || css.root, className); - - return ( - - {menuLabel} - - {options.map(option => { - // check if this option is selected - const selected = initialValue === option.key; - // menu item border class - const menuItemBorderClass = selected ? css.menuItemBorderSelected : css.menuItemBorder; - - return ( - - - - ); - })} - - - - - - ); - } -} - SelectSingleFilter.defaultProps = { - rootClassName: null, - className: null, - initialValue: null, - contentPlacementOffset: 0, + showAsPopup: false, }; SelectSingleFilter.propTypes = { - rootClassName: string, - className: string, - urlParam: string.isRequired, - label: string.isRequired, - onSelect: func.isRequired, - options: arrayOf( - shape({ - key: string.isRequired, - label: string.isRequired, - }) - ).isRequired, - initialValue: string, - contentPlacementOffset: number, + showAsPopup: bool, }; export default SelectSingleFilter; diff --git a/src/components/SelectSingleFilterPlain/SelectSingleFilterPlain.css b/src/components/SelectSingleFilter/SelectSingleFilterPlain.css similarity index 100% rename from src/components/SelectSingleFilterPlain/SelectSingleFilterPlain.css rename to src/components/SelectSingleFilter/SelectSingleFilterPlain.css diff --git a/src/components/SelectSingleFilterPlain/SelectSingleFilterPlain.js b/src/components/SelectSingleFilter/SelectSingleFilterPlain.js similarity index 98% rename from src/components/SelectSingleFilterPlain/SelectSingleFilterPlain.js rename to src/components/SelectSingleFilter/SelectSingleFilterPlain.js index 63a085c9..e99cfb07 100644 --- a/src/components/SelectSingleFilterPlain/SelectSingleFilterPlain.js +++ b/src/components/SelectSingleFilter/SelectSingleFilterPlain.js @@ -57,7 +57,7 @@ class SelectSingleFilterPlain extends Component { {label}
diff --git a/src/components/SelectSingleFilter/SelectSingleFilter.css b/src/components/SelectSingleFilter/SelectSingleFilterPopup.css similarity index 100% rename from src/components/SelectSingleFilter/SelectSingleFilter.css rename to src/components/SelectSingleFilter/SelectSingleFilterPopup.css diff --git a/src/components/SelectSingleFilter/SelectSingleFilterPopup.js b/src/components/SelectSingleFilter/SelectSingleFilterPopup.js new file mode 100644 index 00000000..bcce2899 --- /dev/null +++ b/src/components/SelectSingleFilter/SelectSingleFilterPopup.js @@ -0,0 +1,111 @@ +import React, { Component } from 'react'; +import { string, func, arrayOf, shape, number } from 'prop-types'; +import { FormattedMessage } from 'react-intl'; +import classNames from 'classnames'; + +import { Menu, MenuContent, MenuItem, MenuLabel } from '..'; +import css from './SelectSingleFilterPopup.css'; + +const optionLabel = (options, key) => { + const option = options.find(o => o.key === key); + return option ? option.label : key; +}; + +class SelectSingleFilterPopup extends Component { + constructor(props) { + super(props); + + this.state = { isOpen: false }; + this.onToggleActive = this.onToggleActive.bind(this); + this.selectOption = this.selectOption.bind(this); + } + + onToggleActive(isOpen) { + this.setState({ isOpen: isOpen }); + } + + selectOption(urlParam, option) { + this.setState({ isOpen: false }); + this.props.onSelect(urlParam, option); + } + + render() { + const { + rootClassName, + className, + urlParam, + label, + options, + initialValue, + contentPlacementOffset, + } = this.props; + + // resolve menu label text and class + const menuLabel = initialValue ? optionLabel(options, initialValue) : label; + const menuLabelClass = initialValue ? css.menuLabelSelected : css.menuLabel; + + const classes = classNames(rootClassName || css.root, className); + + return ( + + {menuLabel} + + {options.map(option => { + // check if this option is selected + const selected = initialValue === option.key; + // menu item border class + const menuItemBorderClass = selected ? css.menuItemBorderSelected : css.menuItemBorder; + + return ( + + + + ); + })} + + + + + + ); + } +} + +SelectSingleFilterPopup.defaultProps = { + rootClassName: null, + className: null, + initialValue: null, + contentPlacementOffset: 0, +}; + +SelectSingleFilterPopup.propTypes = { + rootClassName: string, + className: string, + urlParam: string.isRequired, + label: string.isRequired, + onSelect: func.isRequired, + options: arrayOf( + shape({ + key: string.isRequired, + label: string.isRequired, + }) + ).isRequired, + initialValue: string, + contentPlacementOffset: number, +}; + +export default SelectSingleFilterPopup; diff --git a/src/components/index.js b/src/components/index.js index 167705b3..2f82f024 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -2,6 +2,7 @@ export { default as ActivityFeed } from './ActivityFeed/ActivityFeed'; export { default as AddImages } from './AddImages/AddImages'; export { default as Avatar, AvatarMedium, AvatarLarge } from './Avatar/Avatar'; export { default as BookingBreakdown } from './BookingBreakdown/BookingBreakdown'; +export { default as BookingDateRangeFilter } from './BookingDateRangeFilter/BookingDateRangeFilter'; export { default as Button, PrimaryButton, SecondaryButton, InlineTextButton } from './Button/Button'; export { default as BookingPanel } from './BookingPanel/BookingPanel'; export { default as CookieConsent } from './CookieConsent/CookieConsent'; @@ -22,12 +23,15 @@ export { default as FieldCheckbox } from './FieldCheckbox/FieldCheckbox'; export { default as FieldCheckboxGroup } from './FieldCheckboxGroup/FieldCheckboxGroup'; export { default as FieldCurrencyInput } from './FieldCurrencyInput/FieldCurrencyInput'; export { default as FieldDateInput } from './FieldDateInput/FieldDateInput'; +export { default as FieldDateRangeController } from './FieldDateRangeController/FieldDateRangeController'; export { default as FieldDateRangeInput } from './FieldDateRangeInput/FieldDateRangeInput'; export { default as FieldRadioButton } from './FieldRadioButton/FieldRadioButton'; export { default as FieldPhoneNumberInput } from './FieldPhoneNumberInput/FieldPhoneNumberInput'; export { default as FieldReviewRating } from './FieldReviewRating/FieldReviewRating'; export { default as FieldSelect } from './FieldSelect/FieldSelect'; export { default as FieldTextInput } from './FieldTextInput/FieldTextInput'; +export { default as FilterPlain } from './FilterPlain/FilterPlain'; +export { default as FilterPopup } from './FilterPopup/FilterPopup'; export { default as Footer } from './Footer/Footer'; export { default as Form } from './Form/Form'; export { default as IconAdd } from './IconAdd/IconAdd'; @@ -74,6 +78,7 @@ export { default as NamedLink } from './NamedLink/NamedLink'; export { default as NamedRedirect } from './NamedRedirect/NamedRedirect'; export { default as NotificationBadge } from './NotificationBadge/NotificationBadge'; export { default as OrderDiscussionPanel } from './OrderDiscussionPanel/OrderDiscussionPanel'; +export { default as OutsideClickHandler } from './OutsideClickHandler/OutsideClickHandler'; export { default as Page } from './Page/Page'; export { default as PaginationLinks } from './PaginationLinks/PaginationLinks'; export { default as PriceFilter } from './PriceFilter/PriceFilter'; @@ -98,9 +103,7 @@ export { default as SectionHowItWorks } from './SectionHowItWorks/SectionHowItWo export { default as SectionLocations } from './SectionLocations/SectionLocations'; export { default as SectionThumbnailLinks } from './SectionThumbnailLinks/SectionThumbnailLinks'; export { default as SelectMultipleFilter } from './SelectMultipleFilter/SelectMultipleFilter'; -export { default as SelectMultipleFilterPlain } from './SelectMultipleFilterPlain/SelectMultipleFilterPlain'; export { default as SelectSingleFilter } from './SelectSingleFilter/SelectSingleFilter'; -export { default as SelectSingleFilterPlain } from './SelectSingleFilterPlain/SelectSingleFilterPlain'; export { default as StripeBankAccountTokenInputField } from './StripeBankAccountTokenInputField/StripeBankAccountTokenInputField'; export { default as TabNav } from './TabNav/TabNav'; export { LinkTabNavHorizontal, ButtonTabNavHorizontal } from './TabNavHorizontal/TabNavHorizontal'; diff --git a/src/containers/SearchPage/SearchPage.duck.js b/src/containers/SearchPage/SearchPage.duck.js index be796592..27a4a626 100644 --- a/src/containers/SearchPage/SearchPage.duck.js +++ b/src/containers/SearchPage/SearchPage.duck.js @@ -2,6 +2,7 @@ import unionWith from 'lodash/unionWith'; import { storableError } from '../../util/errors'; import { addMarketplaceEntities } from '../../ducks/marketplaceData.duck'; import { convertUnitToSubUnit, unitDivisor } from '../../util/currency'; +import { formatDateStringToUTC, getExclusiveEndDate } from '../../util/dates'; import config from '../../config'; // ================ Action types ================ // @@ -131,11 +132,30 @@ export const searchListings = searchParams => (dispatch, getState, sdk) => { : {}; }; - const { perPage, price, ...rest } = searchParams; + const datesSearchParams = datesParam => { + const values = datesParam ? datesParam.split(',') : []; + const hasValues = datesParam && values.length === 2; + const startDate = hasValues ? values[0] : null; + const endDate = hasValues ? getExclusiveEndDate(values[1]) : null; + + return hasValues + ? { + start: formatDateStringToUTC(startDate), + end: formatDateStringToUTC(endDate), + // Availability can be full or partial. Default value is full. + availability: 'full', + } + : {}; + }; + + const { perPage, price, dates, ...rest } = searchParams; const priceMaybe = priceSearchParams(price); + const datesMaybe = datesSearchParams(dates); + const params = { ...rest, ...priceMaybe, + ...datesMaybe, per_page: perPage, }; diff --git a/src/containers/SearchPage/SearchPage.helpers.js b/src/containers/SearchPage/SearchPage.helpers.js index 0002724a..0be2ba0d 100644 --- a/src/containers/SearchPage/SearchPage.helpers.js +++ b/src/containers/SearchPage/SearchPage.helpers.js @@ -21,18 +21,23 @@ export const validURLParamForExtendedData = (paramName, paramValue, filters) => const valueArray = paramValue ? paramValue.split(',') : []; if (filterConfig && valueArray.length > 0) { - const { min, max } = filterConfig.config || {}; + const { min, max, active } = filterConfig.config || {}; if (filterConfig.options) { + // Single and multiselect filters const allowedValues = filterConfig.options.map(o => o.key); const validValues = intersection(valueArray, allowedValues).join(','); return validValues.length > 0 ? { [paramName]: validValues } : {}; } else if (filterConfig.config && min != null && max != null) { + // Price filter 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 {}; diff --git a/src/containers/SearchPage/SearchPage.js b/src/containers/SearchPage/SearchPage.js index 1b67184c..68033c38 100644 --- a/src/containers/SearchPage/SearchPage.js +++ b/src/containers/SearchPage/SearchPage.js @@ -52,7 +52,7 @@ export class SearchPageComponent extends Component { } filters() { - const { categories, amenities, priceFilterConfig } = this.props; + const { categories, amenities, priceFilterConfig, dateRangeFilterConfig } = this.props; return { categoryFilter: { @@ -67,6 +67,10 @@ export class SearchPageComponent extends Component { paramName: 'price', config: priceFilterConfig, }, + dateRangeFilter: { + paramName: 'dates', + config: dateRangeFilterConfig, + }, }; } @@ -210,6 +214,7 @@ export class SearchPageComponent extends Component { categoryFilter: filters.categoryFilter, amenitiesFilter: filters.amenitiesFilter, priceFilter: filters.priceFilter, + dateRangeFilter: filters.dateRangeFilter, }} /> +); + +export const FilterFormExample = { + component: FilterForm, + props: { + id: 'FilterFormExample', + liveEdit: false, + showAsPopup: true, + contentPlacementOffset: -14, + onSubmit: values => { + console.log(values); + }, + onCancel: () => { + console.log('onCancel called'); + }, + onClear: () => { + console.log('onClear called'); + }, + label: 'Example label', + children: field, + }, + group: 'forms', +}; + +export const FilterFormExampleLiveEdit = { + component: FilterForm, + props: { + id: 'FilterFormExampleLiveEdit', + liveEdit: true, + showAsPopup: false, + contentPlacementOffset: -14, + onChange: values => { + console.log(values); + }, + label: 'Example label', + children: field, + }, + group: 'forms', +}; diff --git a/src/forms/FilterForm/FilterForm.js b/src/forms/FilterForm/FilterForm.js new file mode 100644 index 00000000..3b8cfc6f --- /dev/null +++ b/src/forms/FilterForm/FilterForm.js @@ -0,0 +1,117 @@ +import React from 'react'; +import { bool, func, node, object } from 'prop-types'; +import classNames from 'classnames'; +import { Form as FinalForm, FormSpy } from 'react-final-form'; +import arrayMutators from 'final-form-arrays'; +import { injectIntl, intlShape } from 'react-intl'; + +import { Form } from '../../components'; +import css from './FilterForm.css'; + +const FilterFormComponent = props => { + const { liveEdit, onChange, onSubmit, onCancel, onClear, ...rest } = props; + + if (liveEdit && !onChange) { + throw new Error('FilterForm: if liveEdit is true you need to provide onChange function'); + } + + if (!liveEdit && !(onCancel && onClear && onSubmit)) { + throw new Error( + 'FilterForm: if liveEdit is false you need to provide onCancel, onClear, and onSubmit functions' + ); + } + + const handleChange = formState => { + if (formState.dirty) { + onChange(formState.values); + } + }; + + const formCallbacks = liveEdit ? { onSubmit: () => null } : { onSubmit, onCancel, onClear }; + return ( + { + const { + id, + form, + handleSubmit, + onClear, + onCancel, + style, + paddingClasses, + intl, + children, + } = formRenderProps; + + const handleCancel = () => { + // reset the final form to initialValues + form.reset(); + onCancel(); + }; + + const clear = intl.formatMessage({ id: 'FilterForm.clear' }); + const cancel = intl.formatMessage({ id: 'FilterForm.cancel' }); + const submit = intl.formatMessage({ id: 'FilterForm.submit' }); + + const classes = classNames(css.root); + + return ( +
+
{children}
+ + {liveEdit ? ( + + ) : ( +
+ + + +
+ )} + + ); + }} + /> + ); +}; + +FilterFormComponent.defaultProps = { + liveEdit: false, + style: null, + onCancel: null, + onChange: null, + onClear: null, + onSubmit: null, +}; + +FilterFormComponent.propTypes = { + liveEdit: bool, + onCancel: func, + onChange: func, + onClear: func, + onSubmit: func, + style: object, + children: node.isRequired, + + // form injectIntl + intl: intlShape.isRequired, +}; + +const FilterForm = injectIntl(FilterFormComponent); + +export default FilterForm; diff --git a/src/forms/SelectMultipleFilterForm/SelectMultipleFilterForm.js b/src/forms/SelectMultipleFilterForm/SelectMultipleFilterForm.js deleted file mode 100644 index 88f87222..00000000 --- a/src/forms/SelectMultipleFilterForm/SelectMultipleFilterForm.js +++ /dev/null @@ -1,99 +0,0 @@ -import React from 'react'; -import { arrayOf, bool, func, node, object, shape, string } from 'prop-types'; -import classNames from 'classnames'; -import { Form as FinalForm } from 'react-final-form'; -import { injectIntl, intlShape } from 'react-intl'; -import arrayMutators from 'final-form-arrays'; - -import { FieldCheckboxGroup, Form } from '../../components'; -import css from './SelectMultipleFilterForm.css'; - -const SelectMultipleFilterFormComponent = props => { - return ( - { - const { - form, - handleSubmit, - id, - name, - onClear, - onCancel, - options, - isOpen, - contentRef, - style, - intl, - } = formRenderProps; - const classes = classNames(css.root, { [css.isOpen]: isOpen }); - - const handleCancel = () => { - // reset the final form to initialValues - form.reset(); - onCancel(); - }; - - const clear = intl.formatMessage({ id: 'SelectMultipleFilterForm.clear' }); - const cancel = intl.formatMessage({ id: 'SelectMultipleFilterForm.cancel' }); - const submit = intl.formatMessage({ id: 'SelectMultipleFilterForm.submit' }); - return ( -
- -
- - - -
- - ); - }} - /> - ); -}; - -SelectMultipleFilterFormComponent.defaultProps = { - contentRef: null, - style: null, -}; - -SelectMultipleFilterFormComponent.propTypes = { - id: string.isRequired, - name: string.isRequired, - onClear: func.isRequired, - onCancel: func.isRequired, - options: arrayOf( - shape({ - key: string.isRequired, - label: node.isRequired, - }) - ).isRequired, - isOpen: bool.isRequired, - contentRef: func, - style: object, - - // form injectIntl - intl: intlShape.isRequired, -}; - -const SelectMultipleFilterForm = injectIntl(SelectMultipleFilterFormComponent); - -export default SelectMultipleFilterForm; diff --git a/src/forms/SelectMultipleFilterPlainForm/SelectMultipleFilterPlainForm.js b/src/forms/SelectMultipleFilterPlainForm/SelectMultipleFilterPlainForm.js deleted file mode 100644 index 87e484da..00000000 --- a/src/forms/SelectMultipleFilterPlainForm/SelectMultipleFilterPlainForm.js +++ /dev/null @@ -1,55 +0,0 @@ -import React from 'react'; -import { arrayOf, bool, node, shape, string, func } from 'prop-types'; -import { Form as FinalForm, FormSpy } from 'react-final-form'; -import arrayMutators from 'final-form-arrays'; - -import { FieldCheckboxGroup, Form } from '../../components'; - -const SelectMultipleFilterPlainForm = props => { - const { onChange, ...rest } = props; - - const handleChange = formState => { - if (formState.dirty) { - onChange(formState.values); - } - }; - - return ( - null} - mutators={{ ...arrayMutators }} - render={formRenderProps => { - const { className, id, name, options, twoColumns } = formRenderProps; - return ( -
- - - - ); - }} - /> - ); -}; - -SelectMultipleFilterPlainForm.defaultProps = { - className: null, - twoColumns: false, - onChange: () => null, -}; - -SelectMultipleFilterPlainForm.propTypes = { - className: string, - id: string.isRequired, - name: string.isRequired, - options: arrayOf( - shape({ - key: string.isRequired, - label: node.isRequired, - }) - ).isRequired, - twoColumns: bool, - onChange: func, -}; - -export default SelectMultipleFilterPlainForm; diff --git a/src/forms/index.js b/src/forms/index.js index 399596fa..f3607e97 100644 --- a/src/forms/index.js +++ b/src/forms/index.js @@ -9,6 +9,7 @@ export { default as EditListingPoliciesForm } from './EditListingPoliciesForm/Ed export { default as EditListingPricingForm } from './EditListingPricingForm/EditListingPricingForm'; export { default as EmailVerificationForm } from './EmailVerificationForm/EmailVerificationForm'; export { default as EnquiryForm } from './EnquiryForm/EnquiryForm'; +export { default as FilterForm } from './FilterForm/FilterForm'; export { default as LocationSearchForm } from './LocationSearchForm/LocationSearchForm'; export { default as LoginForm } from './LoginForm/LoginForm'; export { default as PasswordChangeForm } from './PasswordChangeForm/PasswordChangeForm'; @@ -19,8 +20,6 @@ export { default as PriceFilterForm } from './PriceFilterForm/PriceFilterForm'; export { default as ProfileSettingsForm } from './ProfileSettingsForm/ProfileSettingsForm'; export { default as ReviewForm } from './ReviewForm/ReviewForm'; export { default as SendMessageForm } from './SendMessageForm/SendMessageForm'; -export { default as SelectMultipleFilterForm } from './SelectMultipleFilterForm/SelectMultipleFilterForm'; -export { default as SelectMultipleFilterPlainForm } from './SelectMultipleFilterPlainForm/SelectMultipleFilterPlainForm'; export { default as SignupForm } from './SignupForm/SignupForm'; export { default as StripePaymentForm } from './StripePaymentForm/StripePaymentForm'; export { default as TopbarSearchForm } from './TopbarSearchForm/TopbarSearchForm'; diff --git a/src/marketplace-custom-config.js b/src/marketplace-custom-config.js index cd717a38..05c9b08d 100644 --- a/src/marketplace-custom-config.js +++ b/src/marketplace-custom-config.js @@ -51,3 +51,8 @@ export const priceFilterConfig = { max: 1000, step: 5, }; + +// Activate booking dates filter on search page +export const dateRangeFilterConfig = { + active: true, +}; diff --git a/src/translations/en.json b/src/translations/en.json index ec37bd63..54762a51 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -67,6 +67,10 @@ "BookingDatesForm.requiredDate": "Oops, make sure your date is correct!", "BookingDatesForm.timeSlotsError": "Loading listing availability failed. Please refresh the page.", "BookingDatesForm.youWontBeChargedInfo": "You won't be charged yet", + "BookingDateRangeFilter.labelPlain": "Dates", + "BookingDateRangeFilter.labelPopup": "Dates", + "BookingDateRangeFilter.labelSelectedPlain": "{dates}", + "BookingDateRangeFilter.labelSelectedPopup": "{dates}", "BookingPanel.closedListingButtonText": "Sorry, this listing has been closed.", "BookingPanel.ctaButtonMessage": "Request to book", "BookingPanel.hostedBy": "Hosted by {name}", @@ -256,6 +260,10 @@ "FieldReviewRating.star3": "OK - 3 stars", "FieldReviewRating.star4": "Good - 4 stars", "FieldReviewRating.star5": "Awesome - 5 stars", + "FilterForm.cancel": "Cancel", + "FilterForm.clear": "Clear", + "FilterForm.submit": "Apply", + "FilterPlain.clear": "Clear", "Footer.copyright": "© Sharetribe", "Footer.goToFacebook": "Go to Facebook page", "Footer.goToInstagram": "Go to Instagram page", @@ -681,13 +689,9 @@ "SectionLocations.listingsInLocation": "Saunas in {location}", "SectionLocations.title": "Explore exotic locations in Finland", "SelectMultipleFilter.labelSelected": "{labelText} • {count}", - "SelectMultipleFilterForm.cancel": "Cancel", - "SelectMultipleFilterForm.clear": "Clear", - "SelectMultipleFilterForm.submit": "Apply", - "SelectMultipleFilterPlainForm.clear": "Clear", "SelectMultipleFilterPlainForm.labelSelected": "{labelText} • {count}", - "SelectSingleFilter.clear": "Clear", - "SelectSingleFilterPlain.clear": "Clear", + "SelectSingleFilter.popupClear": "Clear", + "SelectSingleFilter.plainClear": "Clear", "SendMessageForm.sendFailed": "Failed to send. Please try again.", "SendMessageForm.sendMessage": "Send message", "SignupForm.emailInvalid": "A valid email address is required", diff --git a/src/translations/es.json b/src/translations/es.json index cbc7c841..fe775c17 100644 --- a/src/translations/es.json +++ b/src/translations/es.json @@ -67,6 +67,10 @@ "BookingDatesForm.requiredDate": "Ups, asegúrate de que la fecha es correcta!", "BookingDatesForm.timeSlotsError": "Error al cargar la disponibilidad del anuncio. Por favor, actualiza la página.", "BookingDatesForm.youWontBeChargedInfo": "Aún no se hará el cargo a tu tarjeta", + "BookingDateRangeFilter.labelPlain": "Fechas", + "BookingDateRangeFilter.labelPopup": "Fechas", + "BookingDateRangeFilter.labelSelectedPlain": "{dates}", + "BookingDateRangeFilter.labelSelectedPopup": "{dates}", "BookingPanel.closedListingButtonText": "Lo sentimos, este anuncio ha sido cerrado.", "BookingPanel.ctaButtonMessage": "Solicitar reserva", "BookingPanel.hostedBy": "Publicado por {name}", @@ -256,6 +260,10 @@ "FieldReviewRating.star3": "Bien - 3 estrellas", "FieldReviewRating.star4": "Muy bien - 4 estrellas", "FieldReviewRating.star5": "Excelente - 5 estrellas", + "FilterForm.cancel": "Cancelar", + "FilterForm.clear": "Borrar", + "FilterForm.submit": "Aplicar", + "FilterPlain.clear": "Borrar", "Footer.copyright": "© Sharetribe", "Footer.goToFacebook": "Ir a Facebook", "Footer.goToInstagram": "Ir a Instagram", @@ -453,6 +461,8 @@ "PasswordResetPage.resetFailed": "Ha ocurrido un error. Por favor, inténtalo de nuevo.", "PasswordResetPage.title": "Restablecer contraseña", "PayoutDetailsForm.accountTypeTitle": "Tipo de cuenta", + "PayoutDetailsForm.additionalOwnersInfoLink": "páginas de soporte de Stripe.", + "PayoutDetailsForm.additionalOwnerInfoText": "Para Hong Kong, Singapur, y países miembros de la Zona Única de Pagos en Euros, Stripe requiere información de cada persona que sea dueña de por lo menos 25% de la empresa. Para mas información, visita las {additionalOwnersInfoLink}", "PayoutDetailsForm.additionalOwnerLabel": "Añadir proprietario adicional", "PayoutDetailsForm.additionalOwnerRemove": "Eliminar propietario adicional", "PayoutDetailsForm.addressTitle": "Dirección", @@ -630,7 +640,7 @@ "ReviewForm.reviewContentPlaceholder": "Describe tu experiencia...", "ReviewForm.reviewContentRequired": "Necesitas añadir un comentario a tu valoración", "ReviewForm.reviewRatingLabel": "Valora tu experiencia", - "ReviewForm.reviewRatingRequired": "Necesitas añadir un comentario a tu valoración", + "ReviewForm.reviewRatingRequired": "Necesitas añadir un comentario a tu valoración.", "ReviewForm.reviewSubmit": "Publicar valoración", "ReviewForm.reviewSubmitAlreadySent": "La valoración ya ha sido enviada. Por favor, actualiza la página.", "ReviewForm.reviewSubmitFailed": "Error al enviar la valoración. Por favor, inténtalo de nuevo.", @@ -679,13 +689,9 @@ "SectionLocations.listingsInLocation": "Saunas en {location}", "SectionLocations.title": "Explora lugares exóticos en Finlandia", "SelectMultipleFilter.labelSelected": "{labelText} • {count}", - "SelectMultipleFilterForm.cancel": "Cancelar", - "SelectMultipleFilterForm.clear": "Borrar", - "SelectMultipleFilterForm.submit": "Aplicar", - "SelectMultipleFilterPlainForm.clear": "Borrar", "SelectMultipleFilterPlainForm.labelSelected": "{labelText} • {count}", - "SelectSingleFilter.clear": "Borrar", - "SelectSingleFilterPlain.clear": "Borrar", + "SelectSingleFilter.popupClear": "Borrar", + "SelectSingleFilter.plainClear": "Borrar", "SendMessageForm.sendFailed": "Error al enviar. Por favor, inténtalo de nuevo.", "SendMessageForm.sendMessage": "Enviar mensaje", "SignupForm.emailInvalid": "Se necesita una dirección de correo electrónico válida", diff --git a/src/translations/fr.json b/src/translations/fr.json index 302e5ae0..220016b5 100644 --- a/src/translations/fr.json +++ b/src/translations/fr.json @@ -67,6 +67,10 @@ "BookingDatesForm.requiredDate": "Oups, vérifiez que la date est correcte", "BookingDatesForm.timeSlotsError": "Le chargement des disponibilités pour cette annonce a échoué. Veuillez rafraîchir la page.", "BookingDatesForm.youWontBeChargedInfo": "Vous ne serez pas facturé immédiatement", + "BookingDateRangeFilter.labelPlain": "Dates", + "BookingDateRangeFilter.labelPopup": "Dates", + "BookingDateRangeFilter.labelSelectedPlain": "{dates}", + "BookingDateRangeFilter.labelSelectedPopup": "{dates}", "BookingPanel.closedListingButtonText": "Navré, cette annonce est close.", "BookingPanel.ctaButtonMessage": "Réserver", "BookingPanel.hostedBy": "Proposé par {name}", @@ -256,6 +260,10 @@ "FieldReviewRating.star3": "Moyen - 3 étoiles", "FieldReviewRating.star4": "Correct - 4 étoiles", "FieldReviewRating.star5": "Génial - 5 étoiles", + "FilterForm.cancel": "Annuler", + "FilterForm.clear": "Effacer", + "FilterForm.submit": "Appliquer", + "FilterPlain.clear": "Effacer", "Footer.copyright": "© Sharetribe", "Footer.goToFacebook": "Aller sur la page Facebook", "Footer.goToInstagram": "Aller sur la page Instagram", @@ -453,6 +461,8 @@ "PasswordResetPage.resetFailed": "La réinitialisation a échoué.", "PasswordResetPage.title": "Réinitialiser le mot de passe", "PayoutDetailsForm.accountTypeTitle": "Type de compte", + "PayoutDetailsForm.additionalOwnersInfoLink": "le support Stripe", + "PayoutDetailsForm.additionalOwnerInfoText": "Pour Hong Kong, Singapore and les pays membres de la zone Euro, Stripe requiert les informations de chaque personne possédant au moins 24% de l'entreprise. Pour plus d'information, parcourez {additionalOwnersInfoLink}.", "PayoutDetailsForm.additionalOwnerLabel": "Ajouter un autre propriétaire", "PayoutDetailsForm.additionalOwnerRemove": "Enlever un propriétaire", "PayoutDetailsForm.addressTitle": "Adresse", @@ -488,7 +498,7 @@ "PayoutDetailsForm.companyAddressTitle": "Adresse de l'entreprise", "PayoutDetailsForm.companyDetailsTitle": "Détails de l'entreprise", "PayoutDetailsForm.companyNameLabel": "Nom de l'entreprise", - "PayoutDetailsForm.companyNamePlaceholder": "Entrez le nom de l'entreprise...", + "PayoutDetailsForm.companyNamePlaceholder": "Entrez le nom de l'entreprise…", "PayoutDetailsForm.companyNameRequired": "Le nom de l'entreprise est requis", "PayoutDetailsForm.companyTaxIdLabel.AT": "Firmenbuchnummer (FN)", "PayoutDetailsForm.companyTaxIdLabel.AU": "ACN/ABN - TFN de l'entreprise", @@ -511,7 +521,7 @@ "PayoutDetailsForm.companyTaxIdLabel.PT": "Numéro Contribuinte", "PayoutDetailsForm.companyTaxIdLabel.SE": "Numéro d'organisation", "PayoutDetailsForm.companyTaxIdLabel.US": "Identifiant taxes (Tax ID)", - "PayoutDetailsForm.companyTaxIdPlaceholder": "Entrez {idName}", + "PayoutDetailsForm.companyTaxIdPlaceholder": "Entrez {idName}…", "PayoutDetailsForm.companyTaxIdRequired": "{idName} est requis", "PayoutDetailsForm.countryLabel": "Pays", "PayoutDetailsForm.countryNames.AT": "Autriche", @@ -679,13 +689,9 @@ "SectionLocations.listingsInLocation": "Saunas à {location}", "SectionLocations.title": "Réchauffez-vous depuis une destination exotique en Finlande", "SelectMultipleFilter.labelSelected": "{labelText} • {count}", - "SelectMultipleFilterForm.cancel": "Annuler", - "SelectMultipleFilterForm.clear": "Effacer", - "SelectMultipleFilterForm.submit": "Valider", - "SelectMultipleFilterPlainForm.clear": "Effacer", "SelectMultipleFilterPlainForm.labelSelected": "{labelText} • {count}", - "SelectSingleFilter.clear": "Effacer", - "SelectSingleFilterPlain.clear": "Effacer", + "SelectSingleFilter.popupClear": "Effacer", + "SelectSingleFilter.plainClear": "Effacer", "SendMessageForm.sendFailed": "Impossible d'envoyer. Veuillez essayer de nouveau.", "SendMessageForm.sendMessage": "Envoyer le message", "SignupForm.emailInvalid": "Une adresse email valide est requise.", diff --git a/src/util/dates.js b/src/util/dates.js index a5cbdbf1..326ae73e 100644 --- a/src/util/dates.js +++ b/src/util/dates.js @@ -169,3 +169,59 @@ export const formatDate = (intl, todayString, d) => { return `${formattedDate}, ${formattedTime}`; }; + +/** + * Converts string given in ISO8601 format to date object. + * This is used e.g. when when dates are parsed form urlParams + * + * @param {String} dateString in 'YYYY-MM-DD'format + * + * @returns {Date} parsed date object + */ +export const parseDateFromISO8601 = dateString => { + return moment(dateString, 'YYYY-MM-DD').toDate(); +}; + +/** + * Converts date to string ISO8601 format ('YYYY-MM-DD'). + * This string is used e.g. in urlParam. + * + * @param {Date} date + * + * @returns {String} string in 'YYYY-MM-DD'format + */ + +export const stringifyDateToISO8601 = date => { + return moment(date).format('YYYY-MM-DD'); +}; + +/** + * Formats string ('YYYY-MM-DD') to UTC format ('0000-00-00T00:00:00.000Z'). + * This is used in search query. + * + * @param {String} string in 'YYYY-MM-DD'format + * + * @returns {String} string in '0000-00-00T00:00:00.000Z' format + */ + +export const formatDateStringToUTC = dateString => { + return moment.utc(dateString).toDate(); +}; + +/** + * Formats string ('YYYY-MM-DD') to UTC format ('0000-00-00T00:00:00.000Z') and adds one day. + * This is used as end date of the search query. + * One day must be added because end of the availability is exclusive in API. + * + * @param {String} string in 'YYYY-MM-DD'format + * + * @returns {String} string in '0000-00-00T00:00:00.000Z' format + */ + +export const getExclusiveEndDate = dateString => { + return moment + .utc(dateString) + .add(1, 'days') + .startOf('day') + .toDate(); +}; diff --git a/src/util/dates.test.js b/src/util/dates.test.js index 5cd3bacf..daab97c2 100644 --- a/src/util/dates.test.js +++ b/src/util/dates.test.js @@ -1,5 +1,13 @@ import { fakeIntl } from './test-data'; -import { isDate, isSameDate, nightsBetween, daysBetween, formatDate } from './dates'; +import { + isDate, + isSameDate, + nightsBetween, + daysBetween, + formatDate, + parseDateFromISO8601, + stringifyDateToISO8601, +} from './dates'; describe('date utils', () => { describe('isDate()', () => { @@ -87,4 +95,19 @@ describe('date utils', () => { expect(formatDate(fakeIntl, 'Today', d)).toEqual('2017-11-22, 13:51'); }); }); + + describe('parseDateFromISO8601()', () => { + it('should return date', () => { + const dateString = '2018-11-23'; + const date = new Date(2018, 10, 23); + expect(parseDateFromISO8601(dateString)).toEqual(date); + }); + }); + + describe('stringifyDateToISO8601()', () => { + it('should return string in YYYY-MM-DD format', () => { + const date = new Date(2018, 10, 23); + expect(stringifyDateToISO8601(date)).toEqual('2018-11-23'); + }); + }); }); diff --git a/src/util/types.js b/src/util/types.js index d3a981f6..88932196 100644 --- a/src/util/types.js +++ b/src/util/types.js @@ -464,7 +464,18 @@ const filterWithPriceConfig = shape({ }).isRequired, }); -propTypes.filterConfig = oneOfType([filterWithOptions, filterWithPriceConfig]); +const filterIsActiveConfig = shape({ + paramName: string.isRequired, + config: shape({ + active: bool.isRequired, + }).isRequired, +}); + +propTypes.filterConfig = oneOfType([ + filterWithOptions, + filterWithPriceConfig, + filterIsActiveConfig, +]); export const ERROR_CODE_TRANSACTION_LISTING_NOT_FOUND = 'transaction-listing-not-found'; export const ERROR_CODE_TRANSACTION_INVALID_TRANSITION = 'transaction-invalid-transition';