Merge pull request #949 from sharetribe/date-filter

Date filter
This commit is contained in:
Vesa Luusua 2019-01-29 13:22:52 +02:00 committed by GitHub
commit 47cda11c90
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
54 changed files with 2130 additions and 649 deletions

View file

@ -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/

View file

@ -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
`<div className={css.filters}>` element
`<div className={css.filters}>` 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
`<div className={css.filtersWrapper}>` element to render the filters
- use the `SelectSingleFilter` and `SelectMultipleFilter` components inside the
`<div className={css.filtersWrapper}>` 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
`<div className={css.filtersWrapper}>` element to render the filters
- use the `SelectSingleFilter` and `SelectMultipleFilter` components inside the
`<div className={css.filtersWrapper}>` 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.

View file

@ -0,0 +1,5 @@
@import '../../marketplace.css';
.popupSize {
padding: 0 10px 7px 10px;
}

View file

@ -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 (
<BookingDateRangeFilter
{...props}
initialValues={initialValues}
onSubmit={(urlParam, values) => {
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',
};

View file

@ -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 ? (
<FilterPopup
className={className}
rootClassName={rootClassName}
popupClassName={css.popupSize}
label={labelForPopup}
isSelected={isSelected}
id={`${id}.popup`}
showAsPopup
contentPlacementOffset={contentPlacementOffset}
onSubmit={onSubmit}
{...onClearPopupMaybe}
{...onCancelPopupMaybe}
initialValues={initialValues}
urlParam={urlParam}
{...rest}
>
<FieldDateRangeController
name="dates"
controllerRef={node => {
this.popupControllerRef = node;
}}
/>
</FilterPopup>
) : (
<FilterPlain
className={className}
rootClassName={rootClassName}
label={labelForPlain}
isSelected={isSelected}
id={`${id}.plain`}
liveEdit
contentPlacementOffset={contentPlacementOffset}
onSubmit={onSubmit}
{...onClearPlainMaybe}
initialValues={initialValues}
urlParam={urlParam}
{...rest}
>
<FieldDateRangeController
name="dates"
controllerRef={node => {
this.plainControllerRef = node;
}}
/>
</FilterPlain>
);
}
}
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;

View file

@ -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(
<BookingDateRangeFilterComponent
id="BookingDateRangeFilter"
urlParam="dates"
liveEdit={false}
showAsPopup={true}
contentPlacementOffset={-14}
initialValues={{}}
onSubmit={() => null}
intl={fakeIntl}
/>
);
expect(tree).toMatchSnapshot();
});
it('matches plain snapshot', () => {
const tree = renderShallow(
<BookingDateRangeFilterComponent
id="BookingDateRangeFilter"
urlParam="dates"
liveEdit={true}
showAsPopup={false}
contentPlacementOffset={-14}
initialValues={{}}
onSubmit={() => null}
intl={fakeIntl}
/>
);
expect(tree).toMatchSnapshot();
});
});

View file

@ -0,0 +1,54 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`BookingDateRangeFilter matches plain snapshot 1`] = `
<InjectIntl(FilterPlainComponent)
className={null}
contentPlacementOffset={-14}
id="BookingDateRangeFilter.plain"
initialValues={
Object {
"dates": null,
}
}
isSelected={false}
label="BookingDateRangeFilter.labelPlain"
liveEdit={true}
onSubmit={[Function]}
rootClassName={null}
urlParam="dates"
>
<FieldDateRangeController
className={null}
controllerRef={[Function]}
name="dates"
rootClassName={null}
/>
</InjectIntl(FilterPlainComponent)>
`;
exports[`BookingDateRangeFilter matches popup snapshot 1`] = `
<InjectIntl(FilterPopup)
className={null}
contentPlacementOffset={-14}
id="BookingDateRangeFilter.popup"
initialValues={
Object {
"dates": null,
}
}
isSelected={false}
label="BookingDateRangeFilter.labelPopup"
liveEdit={false}
onSubmit={[Function]}
rootClassName={null}
showAsPopup={true}
urlParam="dates"
>
<FieldDateRangeController
className={null}
controllerRef={[Function]}
name="dates"
rootClassName={null}
/>
</InjectIntl(FilterPopup)>
`;

View file

@ -0,0 +1,238 @@
@import '../../marketplace.css';
:root {
/*
These variables are available in global scope through ":root"
element (<html> 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,<svg width='14' height='2' viewBox='0 0 14 2' xmlns='http://www.w3.org/2000/svg'><path d='M0 0h14v2H0z' fill='%234A4A4A' fill-rule='evenodd'/></svg>");
background-position: center 28px;
}
& :global(.CalendarDay__today.CalendarDay__selected .renderedDay) {
background-image: url("data:image/svg+xml;utf8,<svg width='14' height='2' viewBox='0 0 14 2' xmlns='http://www.w3.org/2000/svg'><path d='M0 0h14v2H0z' fill='%23FFF' fill-rule='evenodd'/></svg>");
}
& :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);
}

View file

@ -0,0 +1,7 @@
import DateRangeController from './DateRangeController';
export const DateRangeControllerExample = {
component: DateRangeController,
props: {},
group: 'custom inputs',
};

View file

@ -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 => (
<IconArrowHead {...props} direction="left" rootClassName={css.arrowIcon} />
);
const NextIcon = props => (
<IconArrowHead {...props} direction="right" rootClassName={css.arrowIcon} />
);
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: <PrevIcon />,
navNext: <NextIcon />,
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 <span className="renderedDay">{day.format('D')}</span>;
},
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 (
<div className={classes}>
<DayPickerRangeController
{...controllerProps}
startDate={startDate}
endDate={endDate}
onDatesChange={this.onDatesChange}
focusedInput={this.state.focusedInput}
onFocusChange={this.onFocusChange}
/>
</div>
);
}
}
DateRangeController.defaultProps = {
rootClassName: null,
className: null,
...defaultProps,
};
const { string } = PropTypes;
DateRangeController.propTypes = {
rootClassName: string,
className: string,
};
export default DateRangeController;

View file

@ -0,0 +1,40 @@
import React from 'react';
import { Form as FinalForm } from 'react-final-form';
import FieldDateRangeController from './FieldDateRangeController';
const FormComponent = props => (
<FinalForm
{...props}
render={formRenderProps => {
const { style, handleSubmit, onChange, dirty } = formRenderProps;
const handleChange = dirty ? onChange : () => null;
return (
<form
style={style}
onSubmit={e => {
e.preventDefault();
handleSubmit(e);
}}
>
<FieldDateRangeController name="dates" onChange={handleChange} />
</form>
);
}}
/>
);
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',
};

View file

@ -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 <DateRangeController ref={controllerRef} {...input} {...rest} />;
};
const FieldDateRangeController = props => {
return <Field component={component} {...props} />;
};
FieldDateRangeController.defaultProps = {
rootClassName: null,
className: null,
};
FieldDateRangeController.propTypes = {
rootClassName: string,
className: string,
};
export default FieldDateRangeController;

View file

@ -154,7 +154,7 @@
width: 100%;
height: var(--DateRangeInput_selectionHeight);
background-image: url("data:image/svg+xml;utf8,<svg width='14' height='2' viewBox='0 0 14 2' xmlns='http://www.w3.org/2000/svg'><path d='M0 0h14v2H0z' fill='%23FFF' fill-rule='evenodd'/></svg>");
background-position: center 28px;
background-position: center 34px;
}
/* Remove default bg-color and use our extra span instead '.renderedDay' */

View file

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

View file

@ -0,0 +1,25 @@
import React from 'react';
import { FieldTextInput } from '../../components';
import FilterPlain from './FilterPlain';
const id = 'FilterPlainExample';
const field = <FieldTextInput type="text" id={`${id}.input1`} name="input1" label="Input:" />;
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',
};

View file

@ -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 (
<div className={classes}>
<div className={labelClass}>
<button type="button" className={css.labelButton} onClick={this.toggleIsOpen}>
<span className={labelClass}>{label}</span>
</button>
<button type="button" className={css.clearButton} onClick={this.handleClear}>
<FormattedMessage id={'FilterPlain.clear'} />
</button>
</div>
<div
id={id}
className={classNames(plainClassName, css.plain, { [css.isOpen]: this.state.isOpen })}
ref={node => {
this.filterContent = node;
}}
>
<FilterForm
id={`${id}.form`}
liveEdit
contentPlacementOffset={contentPlacementOffset}
onChange={this.handleChange}
initialValues={initialValues}
>
{children}
</FilterForm>
</div>
</div>
);
}
}
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;

View file

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

View file

@ -0,0 +1,25 @@
import React from 'react';
import { FieldTextInput } from '../../components';
import FilterPopup from './FilterPopup';
const id = 'FilterPopupExample';
const field = <FieldTextInput type="text" id={`${id}.input1`} name="input1" label="Input:" />;
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',
};

View file

@ -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 (
<OutsideClickHandler onOutsideClick={this.handleBlur}>
<div
className={classes}
onKeyDown={this.handleKeyDown}
ref={node => {
this.filter = node;
}}
>
<button className={labelStyles} onClick={() => this.toggleOpen()}>
{label}
</button>
<div
id={id}
className={popupClasses}
ref={node => {
this.filterContent = node;
}}
style={contentStyle}
>
{this.state.isOpen ? (
<FilterForm
id={`${id}.form`}
paddingClasses={popupSizeClasses}
showAsPopup
contentPlacementOffset={contentPlacementOffset}
initialValues={initialValues}
onSubmit={this.handleSubmit}
onCancel={this.handleCancel}
onClear={this.handleClear}
>
{children}
</FilterForm>
) : null}
</div>
</div>
</OutsideClickHandler>
);
}
}
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);

View file

@ -0,0 +1,5 @@
@import '../../marketplace.css';
.root {
display: inline-block;
}

View file

@ -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 (
<OutsideClickHandler onOutsideClick={this.handleClick}>
<div style={childStyle}>
<h3>{this.state.message}</h3>
</div>
</OutsideClickHandler>
);
}
}
export const FilterPopupExample = {
component: OutsideClickHandlerWrapper,
props: {},
group: 'misc',
};

View file

@ -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 (
<div className={classes} ref={node => (this.node = node)}>
{children}
</div>
);
}
}
OutsideClickHandler.defaultProps = {
rootClassName: null,
className: null,
};
OutsideClickHandler.propTypes = {
rootClassName: string,
className: string,
onOutsideClick: func.isRequired,
children: node.isRequired,
};

View file

@ -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 ? (
<SelectSingleFilter
urlParam={categoryFilter.paramName}
label={categoryLabel}
onSelect={handleSelectOption}
showAsPopup
options={categoryFilter.options}
initialValue={initialCategory}
contentPlacementOffset={FILTER_DROPDOWN_OFFSET}
@ -125,7 +165,8 @@ const SearchFiltersComponent = props => {
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 ? (
<BookingDateRangeFilter
id="SearchFilters.dateRangeFilter"
urlParam={dateRangeFilter.paramName}
onSubmit={handleDateRange}
showAsPopup
contentPlacementOffset={FILTER_DROPDOWN_OFFSET}
initialValues={initialDateRange}
/>
) : null;
const toggleSearchFiltersPanelButtonClasses =
isSearchFiltersPanelOpen || searchFiltersPanelSelectedCount > 0
? css.searchFiltersPanelOpen
@ -167,6 +220,7 @@ const SearchFiltersComponent = props => {
{categoryFilterElement}
{amenitiesFilterElement}
{priceFilterElement}
{dateRangeFilterElement}
{toggleSearchFiltersPanelButton}
</div>
@ -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,

View file

@ -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 ? (
<SelectSingleFilterPlain
<SelectSingleFilter
urlParam={categoryFilter.paramName}
label={categoryLabel}
onSelect={this.handleSelectSingle}
liveEdit
options={categoryFilter.options}
initialValue={initialCategory}
intl={intl}
@ -201,12 +237,13 @@ class SearchFiltersMobileComponent extends Component {
const initialAmenities = this.initialValues(amenitiesFilter.paramName);
const amenitiesFilterElement = amenitiesFilter ? (
<SelectMultipleFilterPlain
<SelectMultipleFilter
id="SearchFiltersMobile.amenitiesFilter"
name="amenities"
urlParam={amenitiesFilter.paramName}
label={amenitiesLabel}
onSelect={this.handleSelectMultiple}
onSubmit={this.handleSelectMultiple}
liveEdit
options={amenitiesFilter.options}
initialValues={initialAmenities}
/>
@ -225,6 +262,20 @@ class SearchFiltersMobileComponent extends Component {
/>
) : null;
const initialDateRange = this.initialDateRangeValue(dateRangeFilter.paramName);
const dateRangeFilterElement =
dateRangeFilter && dateRangeFilter.config.active ? (
<BookingDateRangeFilter
id="SearchFilters.dateRangeFilter"
urlParam={dateRangeFilter.paramName}
onSubmit={this.handleDateRange}
liveEdit
showAsPopup={false}
initialValues={initialDateRange}
/>
) : null;
return (
<div className={classes}>
<div className={css.searchResultSummary}>
@ -253,11 +304,15 @@ class SearchFiltersMobileComponent extends Component {
<FormattedMessage id={'SearchFiltersMobile.resetAll'} />
</button>
</div>
<div className={css.filtersWrapper}>
{categoryFilterElement}
{amenitiesFilterElement}
{priceFilterElement}
</div>
{this.state.isFiltersOpenOnMobile ? (
<div className={css.filtersWrapper}>
{categoryFilterElement}
{amenitiesFilterElement}
{priceFilterElement}
{dateRangeFilterElement}
</div>
) : null}
<div className={css.showListingsContainer}>
<Button className={css.showListingsButton} onClick={this.closeFilters}>
{showListingsLabel}
@ -278,6 +333,8 @@ SearchFiltersMobileComponent.defaultProps = {
filterParamNames: [],
categoryFilter: null,
amenitiesFilter: null,
priceFilter: null,
dateRangeFilter: null,
};
SearchFiltersMobileComponent.propTypes = {
@ -296,6 +353,8 @@ SearchFiltersMobileComponent.propTypes = {
filterParamNames: array,
categoriesFilter: propTypes.filterConfig,
amenitiesFilter: propTypes.filterConfig,
priceFilter: propTypes.filterConfig,
dateRangeFilter: propTypes.filterConfig,
// from injectIntl
intl: intlShape.isRequired,

View file

@ -13,16 +13,16 @@
* const initialNewFilterValues = this.initialValues(newFilter.paramName);
*
* const newFilterElement = newFilter ? (
* <SelectMultipleFilterPlain
* <SelectMultipleFilter
* id="SearchFiltersPanel.newFilter"
* name="newFilter"
* urlParam={newFilter.paramName}
* label={this.props.intl.formatMessage({ id: 'SearchFiltersPanel.newFilterLabel' })}
* onSelect={this.handleSelectMultiple}
* onSubmit={this.handleSelectMultiple}
* liveEdit
* options={multiSelectFilterXFromProps}
* initialValues={initialNewFilterValues}
* twoColumns
* />
* />
* ) : null;
*/

View file

@ -41,3 +41,8 @@
border: 1px solid var(--marketplaceColorDark);
}
}
.fieldGroupPlain {
padding-left: 20px;
padding-bottom: 30px;
}

View file

@ -41,11 +41,12 @@ const options = [
];
const handleSubmit = (urlParam, values, history) => {
console.log('Submitting values', values);
const queryParams = values ? `?${stringify({ [urlParam]: values.join(',') })}` : '';
history.push(`${window.location.pathname}${queryParams}`);
};
const AmenitiesFilterComponent = withRouter(props => {
const AmenitiesFilterPopup = withRouter(props => {
const { history, location } = props;
const params = parse(location.search);
@ -54,11 +55,13 @@ const AmenitiesFilterComponent = withRouter(props => {
return (
<SelectMultipleFilter
id="SelectMultipleFilterExample"
id="SelectMultipleFilterPopupExample"
name="amenities"
urlParam={URL_PARAM}
label="Amenities"
onSelect={(urlParam, values) => handleSubmit(urlParam, values, history)}
onSubmit={(urlParam, values) => handleSubmit(urlParam, values, history)}
showAsPopup={true}
liveEdit={false}
options={options}
initialValues={initialValues}
contentPlacementOffset={-14}
@ -66,8 +69,38 @@ const AmenitiesFilterComponent = withRouter(props => {
);
});
export const AmenitiesFilter = {
component: AmenitiesFilterComponent,
export const AmenitiesFilterPopupExample = {
component: AmenitiesFilterPopup,
props: {},
group: 'misc',
group: 'filters',
};
const AmenitiesFilterPlain = withRouter(props => {
const { history, location } = props;
const params = parse(location.search);
const amenities = params[URL_PARAM];
const initialValues = !!amenities ? amenities.split(',') : [];
return (
<SelectMultipleFilter
id="SelectMultipleFilterPlainExample"
name="amenities"
urlParam={URL_PARAM}
label="Amenities"
onSubmit={(urlParam, values) => {
handleSubmit(urlParam, values, history);
}}
showAsPopup={false}
liveEdit={true}
options={options}
initialValues={initialValues}
/>
);
});
export const AmenitiesFilterPlainExample = {
component: AmenitiesFilterPlain,
props: {},
group: 'filters',
};

View file

@ -2,71 +2,21 @@ import React, { Component } from 'react';
import { array, arrayOf, func, number, string } from 'prop-types';
import classNames from 'classnames';
import { injectIntl, intlShape } from 'react-intl';
import { FieldCheckboxGroup } from '../../components';
import { SelectMultipleFilterForm } from '../../forms';
import { FilterPopup, FilterPlain } from '../../components';
import css from './SelectMultipleFilter.css';
const KEY_CODE_ESCAPE = 27;
class SelectMultipleFilter 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 { name, onSelect, urlParam } = this.props;
const paramValues = values[name];
this.setState({ isOpen: false });
onSelect(urlParam, paramValues);
}
handleClear() {
const { onSelect, urlParam } = this.props;
this.setState({ isOpen: false });
onSelect(urlParam, null);
}
handleCancel() {
const { onSelect, initialValues, urlParam } = this.props;
this.setState({ isOpen: false });
onSelect(urlParam, initialValues);
}
handleBlur(event) {
// FocusEvent is fired faster than the link elements native click handler
// gets its own event. Therefore, we need to check the origin of this FocusEvent.
if (!this.filter.contains(event.relatedTarget)) {
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
@ -91,54 +41,94 @@ class SelectMultipleFilter extends Component {
}
render() {
const { rootClassName, className, id, name, label, options, initialValues, intl } = this.props;
const {
rootClassName,
className,
id,
name,
label,
options,
initialValues,
contentPlacementOffset,
onSubmit,
urlParam,
intl,
showAsPopup,
...rest
} = this.props;
const classes = classNames(rootClassName || css.root, className);
const hasInitialValues = initialValues.length > 0;
const labelStyles = hasInitialValues ? css.labelSelected : css.label;
const buttonLabel = hasInitialValues
const labelForPopup = hasInitialValues
? intl.formatMessage(
{ id: 'SelectMultipleFilter.labelSelected' },
{ labelText: label, count: initialValues.length }
)
: label;
const labelForPlain = hasInitialValues
? intl.formatMessage(
{ id: 'SelectMultipleFilterPlainForm.labelSelected' },
{ labelText: label, count: initialValues.length }
)
: label;
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 };
return (
<div
const handleSubmit = (urlParam, values) => {
const usedValue = values ? values[name] : values;
onSubmit(urlParam, usedValue);
};
return showAsPopup ? (
<FilterPopup
className={classes}
onBlur={this.handleBlur}
onKeyDown={this.handleKeyDown}
ref={node => {
this.filter = node;
}}
rootClassName={rootClassName}
popupClassName={css.popupSize}
name={name}
label={labelForPopup}
isSelected={hasInitialValues}
id={`${id}.popup`}
showAsPopup
contentPlacementOffset={contentPlacementOffset}
onSubmit={handleSubmit}
initialValues={namedInitialValues}
urlParam={urlParam}
{...rest}
>
<button className={labelStyles} onClick={() => this.toggleOpen()}>
{buttonLabel}
</button>
<SelectMultipleFilterForm
id={id}
onSubmit={this.handleSubmit}
initialValues={namedInitialValues}
enableReinitialize={true}
<FieldCheckboxGroup
className={css.fieldGroup}
name={name}
onClear={this.handleClear}
onCancel={this.handleCancel}
id={`${id}-checkbox-group`}
options={options}
isOpen={this.state.isOpen}
intl={intl}
contentRef={node => {
this.filterContent = node;
}}
style={contentStyle}
/>
</div>
</FilterPopup>
) : (
<FilterPlain
className={className}
rootClassName={rootClassName}
label={labelForPlain}
isSelected={hasInitialValues}
id={`${id}.plain`}
liveEdit
contentPlacementOffset={contentStyle}
onSubmit={handleSubmit}
initialValues={namedInitialValues}
urlParam={urlParam}
{...rest}
>
<FieldCheckboxGroup
className={css.fieldGroupPlain}
name={name}
id={`${id}-checkbox-group`}
options={options}
/>
</FilterPlain>
);
}
}
@ -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,

View file

@ -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 (
<SelectMultipleFilterPlain
id="SelectMultipleFilterPlainExample"
name="amenities"
urlParam={URL_PARAM}
label="Amenities"
options={options}
onSelect={(urlParam, values) => handleSelect(urlParam, values, history)}
initialValues={initialValues}
/>
);
};
export const AmenitiesFilter = {
component: withRouter(AmenitiesFilterComponent),
props: {},
group: 'misc',
};

View file

@ -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 (
<div className={classes}>
<div className={labelClass}>
<button type="button" className={css.labelButton} onClick={this.toggleIsOpen}>
<span className={labelClass}>{labelText}</span>
</button>
<button type="button" className={css.clearButton} onClick={this.handleClear}>
<FormattedMessage id={'SelectMultipleFilterPlainForm.clear'} />
</button>
</div>
<SelectMultipleFilterPlainForm
id={id}
className={optionsContainerClass}
name={name}
options={options}
initialValues={namedInitialValues}
onChange={this.handleSelect}
twoColumns={twoColumns}
enableReinitialize
keepDirtyOnReinitialize
/>
</div>
);
}
}
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;

View file

@ -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 ? (
<SelectSingleFilterPopup {...rest} />
) : (
<SelectSingleFilterPlain {...rest} />
);
};
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 (
<Menu
className={classes}
useArrow={false}
contentPlacementOffset={contentPlacementOffset}
onToggleActive={this.onToggleActive}
isOpen={this.state.isOpen}
>
<MenuLabel className={menuLabelClass}>{menuLabel}</MenuLabel>
<MenuContent className={css.menuContent}>
{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 (
<MenuItem key={option.key}>
<button
className={css.menuItem}
onClick={() => this.selectOption(urlParam, option.key)}
>
<span className={menuItemBorderClass} />
{option.label}
</button>
</MenuItem>
);
})}
<MenuItem key={'clearLink'}>
<button className={css.clearMenuItem} onClick={() => this.selectOption(urlParam, null)}>
<FormattedMessage id={'SelectSingleFilter.clear'} />
</button>
</MenuItem>
</MenuContent>
</Menu>
);
}
}
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;

View file

@ -57,7 +57,7 @@ class SelectSingleFilterPlain extends Component {
<span className={labelClass}>{label}</span>
</button>
<button className={css.clearButton} onClick={e => this.selectOption(null, e)}>
<FormattedMessage id={'SelectSingleFilterPlain.clear'} />
<FormattedMessage id={'SelectSingleFilter.plainClear'} />
</button>
</div>
<div className={optionsContainerClass}>

View file

@ -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 (
<Menu
className={classes}
useArrow={false}
contentPlacementOffset={contentPlacementOffset}
onToggleActive={this.onToggleActive}
isOpen={this.state.isOpen}
>
<MenuLabel className={menuLabelClass}>{menuLabel}</MenuLabel>
<MenuContent className={css.menuContent}>
{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 (
<MenuItem key={option.key}>
<button
className={css.menuItem}
onClick={() => this.selectOption(urlParam, option.key)}
>
<span className={menuItemBorderClass} />
{option.label}
</button>
</MenuItem>
);
})}
<MenuItem key={'clearLink'}>
<button className={css.clearMenuItem} onClick={() => this.selectOption(urlParam, null)}>
<FormattedMessage id={'SelectSingleFilter.popupClear'} />
</button>
</MenuItem>
</MenuContent>
</Menu>
);
}
}
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;

View file

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

View file

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

View file

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

View file

@ -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,
}}
/>
<ModalInMobile
@ -256,6 +261,7 @@ SearchPageComponent.defaultProps = {
categories: config.custom.categories,
amenities: config.custom.amenities,
priceFilterConfig: config.custom.priceFilterConfig,
dateRangeFilterConfig: config.custom.dateRangeFilterConfig,
activeListingId: null,
};
@ -278,6 +284,7 @@ SearchPageComponent.propTypes = {
max: number.isRequired,
step: number.isRequired,
}),
dateRangeFilterConfig: shape({ active: bool.isRequired }),
// from withRouter
history: shape({

View file

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

View file

@ -4,6 +4,7 @@ import * as AddImages from './components/AddImages/AddImages.example';
import * as Avatar from './components/Avatar/Avatar.example';
import * as BookingBreakdown from './components/BookingBreakdown/BookingBreakdown.example';
import * as BookingPanel from './components/BookingPanel/BookingPanel.example';
import * as BookingDateRangeFilter from './components/BookingDateRangeFilter/BookingDateRangeFilter.example';
import * as Button from './components/Button/Button.example';
import * as ExpandingTextarea from './components/ExpandingTextarea/ExpandingTextarea.example';
import * as FieldBirthdayInput from './components/FieldBirthdayInput/FieldBirthdayInput.example';
@ -12,6 +13,7 @@ import * as FieldCheckbox from './components/FieldCheckbox/FieldCheckbox.example
import * as FieldCheckboxGroup from './components/FieldCheckboxGroup/FieldCheckboxGroup.example';
import * as FieldCurrencyInput from './components/FieldCurrencyInput/FieldCurrencyInput.example';
import * as FieldDateInput from './components/FieldDateInput/FieldDateInput.example';
import * as FieldDateRangeController from './components/FieldDateRangeController/FieldDateRangeController.example';
import * as FieldDateRangeInput from './components/FieldDateRangeInput/FieldDateRangeInput.example';
import * as FieldPhoneNumberInput from './components/FieldPhoneNumberInput/FieldPhoneNumberInput.example';
import * as FieldRadioButton from './components/FieldRadioButton/FieldRadioButton.example';
@ -19,6 +21,8 @@ import * as FieldRangeSlider from './components/FieldRangeSlider/FieldRangeSlide
import * as FieldReviewRating from './components/FieldReviewRating/FieldReviewRating.example';
import * as FieldSelect from './components/FieldSelect/FieldSelect.example';
import * as FieldTextInput from './components/FieldTextInput/FieldTextInput.example';
import * as FilterPlain from './components/FilterPlain/FilterPlain.example';
import * as FilterPopup from './components/FilterPopup/FilterPopup.example';
import * as Footer from './components/Footer/Footer.example';
import * as IconAdd from './components/IconAdd/IconAdd.example';
import * as IconBannedUser from './components/IconBannedUser/IconBannedUser.example';
@ -46,6 +50,7 @@ import * as Menu from './components/Menu/Menu.example';
import * as Modal from './components/Modal/Modal.example';
import * as ModalInMobile from './components/ModalInMobile/ModalInMobile.example';
import * as NamedLink from './components/NamedLink/NamedLink.example';
import * as OutsideClickHandler from './components/OutsideClickHandler/OutsideClickHandler.example';
import * as PaginationLinks from './components/PaginationLinks/PaginationLinks.example';
import * as PriceFilter from './components/PriceFilter/PriceFilter.example';
import * as PropertyGroup from './components/PropertyGroup/PropertyGroup.example';
@ -55,7 +60,6 @@ import * as ReviewRating from './components/ReviewRating/ReviewRating.example';
import * as Reviews from './components/Reviews/Reviews.example';
import * as SectionThumbnailLinks from './components/SectionThumbnailLinks/SectionThumbnailLinks.example';
import * as SelectMultipleFilter from './components/SelectMultipleFilter/SelectMultipleFilter.example';
import * as SelectMultipleFilterPlain from './components/SelectMultipleFilterPlain/SelectMultipleFilterPlain.example';
import * as StripeBankAccountTokenInputField from './components/StripeBankAccountTokenInputField/StripeBankAccountTokenInputField.example';
import * as TabNav from './components/TabNav/TabNav.example';
import * as TabNavHorizontal from './components/TabNavHorizontal/TabNavHorizontal.example';
@ -73,6 +77,7 @@ import * as EditListingPoliciesForm from './forms/EditListingPoliciesForm/EditLi
import * as EditListingPricingForm from './forms/EditListingPricingForm/EditListingPricingForm.example';
import * as EmailVerificationForm from './forms/EmailVerificationForm/EmailVerificationForm.example';
import * as EnquiryForm from './forms/EnquiryForm/EnquiryForm.example';
import * as FilterForm from './forms/FilterForm/FilterForm.example';
import * as LoginForm from './forms/LoginForm/LoginForm.example';
import * as PasswordRecoveryForm from './forms/PasswordRecoveryForm/PasswordRecoveryForm.example';
import * as PasswordResetForm from './forms/PasswordResetForm/PasswordResetForm.example';
@ -91,6 +96,7 @@ export {
AddImages,
Avatar,
BookingBreakdown,
BookingDateRangeFilter,
BookingDatesForm,
BookingPanel,
Button,
@ -110,6 +116,7 @@ export {
FieldCheckbox,
FieldCheckboxGroup,
FieldCurrencyInput,
FieldDateRangeController,
FieldDateInput,
FieldDateRangeInput,
FieldPhoneNumberInput,
@ -118,6 +125,9 @@ export {
FieldReviewRating,
FieldSelect,
FieldTextInput,
FilterForm,
FilterPlain,
FilterPopup,
Footer,
IconAdd,
IconBannedUser,
@ -146,6 +156,7 @@ export {
Modal,
ModalInMobile,
NamedLink,
OutsideClickHandler,
PaginationLinks,
PasswordRecoveryForm,
PasswordResetForm,
@ -159,7 +170,6 @@ export {
Reviews,
SectionThumbnailLinks,
SelectMultipleFilter,
SelectMultipleFilterPlain,
SendMessageForm,
SignupForm,
StripeBankAccountTokenInputField,

View file

@ -1,41 +1,15 @@
@import '../../marketplace.css';
.root {
/* By default hide the content */
visibility: hidden;
opacity: 0;
pointer-events: none;
/* Position */
position: absolute;
z-index: var(--zIndexPopup);
/* Layout */
margin-top: 7px;
padding: 15px 30px 20px 30px;
/* Borders */
background-color: var(--matterColorLight);
border-top: 1px solid var(--matterColorNegative);
box-shadow: var(--boxShadowPopup);
border-radius: 4px;
transition: var(--transitionStyleButton);
outline: none;
}
.isOpen {
visibility: visible;
opacity: 1;
pointer-events: auto;
}
.fieldGroup {
margin-bottom: 35px;
white-space: nowrap;
.contentWrapper {
margin-bottom: 24px;
}
.buttonsWrapper {
display: flex;
padding: 0 30px 16px 30px;
}
.clearButton {

View file

@ -0,0 +1,51 @@
import React from 'react';
import FilterForm from './FilterForm';
import { FieldTextInput } from '../../components';
const field = (
<FieldTextInput
id="field"
name="field"
type="textarea"
label="Field label"
placeholder="Write something here"
/>
);
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',
};

View file

@ -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 (
<FinalForm
{...rest}
{...formCallbacks}
mutators={{ ...arrayMutators }}
render={formRenderProps => {
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 (
<Form
id={id}
className={classes}
onSubmit={handleSubmit}
tabIndex="0"
style={{ ...style }}
>
<div className={classNames(paddingClasses || css.contentWrapper)}>{children}</div>
{liveEdit ? (
<FormSpy onChange={handleChange} subscription={{ values: true, dirty: true }} />
) : (
<div className={css.buttonsWrapper}>
<button className={css.clearButton} type="button" onClick={onClear}>
{clear}
</button>
<button className={css.cancelButton} type="button" onClick={handleCancel}>
{cancel}
</button>
<button className={css.submitButton} type="submit">
{submit}
</button>
</div>
)}
</Form>
);
}}
/>
);
};
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;

View file

@ -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 (
<FinalForm
{...props}
mutators={{ ...arrayMutators }}
render={formRenderProps => {
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 (
<Form
className={classes}
onSubmit={handleSubmit}
tabIndex="0"
contentRef={contentRef}
style={style}
>
<FieldCheckboxGroup
className={css.fieldGroup}
name={name}
id={`${id}-checkbox-group`}
options={options}
/>
<div className={css.buttonsWrapper}>
<button className={css.clearButton} type="button" onClick={onClear}>
{clear}
</button>
<button className={css.cancelButton} type="button" onClick={handleCancel}>
{cancel}
</button>
<button className={css.submitButton} type="submit">
{submit}
</button>
</div>
</Form>
);
}}
/>
);
};
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;

View file

@ -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 (
<FinalForm
{...rest}
onSubmit={() => null}
mutators={{ ...arrayMutators }}
render={formRenderProps => {
const { className, id, name, options, twoColumns } = formRenderProps;
return (
<Form className={className}>
<FormSpy onChange={handleChange} subscription={{ values: true, dirty: true }} />
<FieldCheckboxGroup name={name} id={id} options={options} twoColumns={twoColumns} />
</Form>
);
}}
/>
);
};
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;

View file

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

View file

@ -51,3 +51,8 @@ export const priceFilterConfig = {
max: 1000,
step: 5,
};
// Activate booking dates filter on search page
export const dateRangeFilterConfig = {
active: true,
};

View file

@ -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",

View file

@ -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",

View file

@ -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.",

View file

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

View file

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

View file

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