mirror of
https://github.com/kingomarnajjar/flex-template-web.git
synced 2026-07-26 06:47:17 +10:00
commit
47cda11c90
54 changed files with 2130 additions and 649 deletions
|
|
@ -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/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
@import '../../marketplace.css';
|
||||
|
||||
.popupSize {
|
||||
padding: 0 10px 7px 10px;
|
||||
}
|
||||
|
|
@ -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',
|
||||
};
|
||||
153
src/components/BookingDateRangeFilter/BookingDateRangeFilter.js
Normal file
153
src/components/BookingDateRangeFilter/BookingDateRangeFilter.js
Normal 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;
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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)>
|
||||
`;
|
||||
238
src/components/FieldDateRangeController/DateRangeController.css
Normal file
238
src/components/FieldDateRangeController/DateRangeController.css
Normal 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);
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
import DateRangeController from './DateRangeController';
|
||||
|
||||
export const DateRangeControllerExample = {
|
||||
component: DateRangeController,
|
||||
props: {},
|
||||
group: 'custom inputs',
|
||||
};
|
||||
190
src/components/FieldDateRangeController/DateRangeController.js
Normal file
190
src/components/FieldDateRangeController/DateRangeController.js
Normal 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;
|
||||
|
|
@ -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',
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
@ -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' */
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
25
src/components/FilterPlain/FilterPlain.example.js
Normal file
25
src/components/FilterPlain/FilterPlain.example.js
Normal 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',
|
||||
};
|
||||
109
src/components/FilterPlain/FilterPlain.js
Normal file
109
src/components/FilterPlain/FilterPlain.js
Normal 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;
|
||||
77
src/components/FilterPopup/FilterPopup.css
Normal file
77
src/components/FilterPopup/FilterPopup.css
Normal 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;
|
||||
}
|
||||
25
src/components/FilterPopup/FilterPopup.example.js
Normal file
25
src/components/FilterPopup/FilterPopup.example.js
Normal 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',
|
||||
};
|
||||
186
src/components/FilterPopup/FilterPopup.js
Normal file
186
src/components/FilterPopup/FilterPopup.js
Normal 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);
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
@import '../../marketplace.css';
|
||||
|
||||
.root {
|
||||
display: inline-block;
|
||||
}
|
||||
|
|
@ -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',
|
||||
};
|
||||
50
src/components/OutsideClickHandler/OutsideClickHandler.js
Normal file
50
src/components/OutsideClickHandler/OutsideClickHandler.js
Normal 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,
|
||||
};
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
*/
|
||||
|
||||
|
|
|
|||
|
|
@ -41,3 +41,8 @@
|
|||
border: 1px solid var(--marketplaceColorDark);
|
||||
}
|
||||
}
|
||||
|
||||
.fieldGroupPlain {
|
||||
padding-left: 20px;
|
||||
padding-bottom: 30px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
111
src/components/SelectSingleFilter/SelectSingleFilterPopup.js
Normal file
111
src/components/SelectSingleFilter/SelectSingleFilterPopup.js
Normal 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;
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {};
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
51
src/forms/FilterForm/FilterForm.example.js
Normal file
51
src/forms/FilterForm/FilterForm.example.js
Normal 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',
|
||||
};
|
||||
117
src/forms/FilterForm/FilterForm.js
Normal file
117
src/forms/FilterForm/FilterForm.js
Normal 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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -51,3 +51,8 @@ export const priceFilterConfig = {
|
|||
max: 1000,
|
||||
step: 5,
|
||||
};
|
||||
|
||||
// Activate booking dates filter on search page
|
||||
export const dateRangeFilterConfig = {
|
||||
active: true,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue