From 0d30657e90461c0915536280cd18ddb64cf31b65 Mon Sep 17 00:00:00 2001 From: Jenni Nurmi Date: Mon, 12 Nov 2018 10:34:13 +0200 Subject: [PATCH 01/20] Add FilterForm component --- src/examples.js | 2 + src/forms/FilterForm/FilterForm.css | 81 +++++++++++++++ src/forms/FilterForm/FilterForm.example.js | 51 +++++++++ src/forms/FilterForm/FilterForm.js | 115 +++++++++++++++++++++ src/forms/index.js | 1 + src/translations/en.json | 3 + 6 files changed, 253 insertions(+) create mode 100644 src/forms/FilterForm/FilterForm.css create mode 100644 src/forms/FilterForm/FilterForm.example.js create mode 100644 src/forms/FilterForm/FilterForm.js diff --git a/src/examples.js b/src/examples.js index 87ab1e3e..e567c349 100644 --- a/src/examples.js +++ b/src/examples.js @@ -73,6 +73,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'; @@ -118,6 +119,7 @@ export { FieldReviewRating, FieldSelect, FieldTextInput, + FilterForm, Footer, IconAdd, IconBannedUser, diff --git a/src/forms/FilterForm/FilterForm.css b/src/forms/FilterForm/FilterForm.css new file mode 100644 index 00000000..8fb5f9ff --- /dev/null +++ b/src/forms/FilterForm/FilterForm.css @@ -0,0 +1,81 @@ +@import '../../marketplace.css'; + +.root { +} + +.contentWrapper { + margin-bottom: 24px; +} + +.buttonsWrapper { + display: flex; + padding: 0 30px 16px 30px; +} + +.clearButton { + @apply --marketplaceH4FontStyles; + font-weight: var(--fontWeightMedium); + color: var(--matterColorAnti); + + /* Layout */ + margin: 0 auto 0 0; + padding: 0; + + /* Override button styles */ + outline: none; + border: none; + cursor: pointer; + + &:focus, + &:hover { + color: var(--matterColor); + transition: width var(--transitionStyleButton); + } +} + +.cancelButton { + @apply --marketplaceH4FontStyles; + font-weight: var(--fontWeightMedium); + color: var(--matterColorAnti); + + /* Layout */ + margin: 0; + padding: 0; + + /* Override button styles */ + outline: none; + border: none; + cursor: pointer; + + /* clearButton will add all available space between cancelButton, + * but some hard coded margin is still needed + */ + margin-left: 48px; + + &:focus, + &:hover { + color: var(--matterColor); + transition: width var(--transitionStyleButton); + } +} + +.submitButton { + @apply --marketplaceH4FontStyles; + font-weight: var(--fontWeightMedium); + color: var(--marketplaceColor); + + /* Layout */ + margin: 0 0 0 19px; + padding: 0; + + /* Override button styles */ + outline: none; + border: none; + cursor: pointer; + + &:focus, + &:hover { + color: var(--marketplaceColorDark); + transition: width var(--transitionStyleButton); + } +} diff --git a/src/forms/FilterForm/FilterForm.example.js b/src/forms/FilterForm/FilterForm.example.js new file mode 100644 index 00000000..4dea9a08 --- /dev/null +++ b/src/forms/FilterForm/FilterForm.example.js @@ -0,0 +1,51 @@ +import React from 'react'; +import FilterForm from './FilterForm'; +import { FieldTextInput } from '../../components'; + +const field = ( + +); + +export const FilterFormExample = { + component: FilterForm, + props: { + id: 'FilterFormExample', + liveEdit: false, + showAsPopup: true, + contentPlacementOffset: -14, + onSubmit: values => { + console.log(values); + }, + onCancel: () => { + console.log('onCancel called'); + }, + onClear: () => { + console.log('onClear called'); + }, + label: 'Example label', + children: field, + }, + group: 'forms', +}; + +export const FilterFormExampleLiveEdit = { + component: FilterForm, + props: { + id: 'FilterFormExampleLiveEdit', + liveEdit: true, + showAsPopup: false, + contentPlacementOffset: -14, + onChange: values => { + console.log(values); + }, + label: 'Example label', + children: field, + }, + group: 'forms', +}; diff --git a/src/forms/FilterForm/FilterForm.js b/src/forms/FilterForm/FilterForm.js new file mode 100644 index 00000000..a3730870 --- /dev/null +++ b/src/forms/FilterForm/FilterForm.js @@ -0,0 +1,115 @@ +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 { injectIntl, intlShape } from 'react-intl'; + +import { Form } from '../../components'; +import css from './FilterForm.css'; + +const FilterFormComponent = props => { + const { liveEdit, onChange, onSubmit, onCancel, onClear, ...rest } = props; + + if (liveEdit && !onChange) { + throw new Error('FilterForm: if liveEdit is true you need to provide onChange function'); + } + + if (!liveEdit && !(onCancel && onClear && onSubmit)) { + throw new Error( + 'FilterForm: if liveEdit is false you need to provide onCancel, onClear, and onSubmit functions' + ); + } + + const handleChange = formState => { + if (formState.dirty) { + onChange(formState.values); + } + }; + + const formCallbacks = liveEdit ? { onSubmit: () => null } : { onSubmit, onCancel, onClear }; + return ( + { + const { + id, + form, + handleSubmit, + onClear, + onCancel, + style, + paddingClasses, + intl, + children, + } = formRenderProps; + + const handleCancel = () => { + // reset the final form to initialValues + form.reset(); + onCancel(); + }; + + const clear = intl.formatMessage({ id: 'FilterForm.clear' }); + const cancel = intl.formatMessage({ id: 'FilterForm.cancel' }); + const submit = intl.formatMessage({ id: 'FilterForm.submit' }); + + const classes = classNames(css.root); + + return ( +
+
{children}
+ + {liveEdit ? ( + + ) : ( +
+ + + +
+ )} + + ); + }} + /> + ); +}; + +FilterFormComponent.defaultProps = { + liveEdit: false, + style: null, + onCancel: null, + onChange: null, + onClear: null, + onSubmit: null, +}; + +FilterFormComponent.propTypes = { + liveEdit: bool, + onCancel: func, + onChange: func, + onClear: func, + onSubmit: func, + style: object, + children: node.isRequired, + + // form injectIntl + intl: intlShape.isRequired, +}; + +const FilterForm = injectIntl(FilterFormComponent); + +export default FilterForm; diff --git a/src/forms/index.js b/src/forms/index.js index 399596fa..198f53de 100644 --- a/src/forms/index.js +++ b/src/forms/index.js @@ -9,6 +9,7 @@ export { default as EditListingPoliciesForm } from './EditListingPoliciesForm/Ed export { default as EditListingPricingForm } from './EditListingPricingForm/EditListingPricingForm'; export { default as EmailVerificationForm } from './EmailVerificationForm/EmailVerificationForm'; export { default as EnquiryForm } from './EnquiryForm/EnquiryForm'; +export { default as FilterForm } from './FilterForm/FilterForm'; export { default as LocationSearchForm } from './LocationSearchForm/LocationSearchForm'; export { default as LoginForm } from './LoginForm/LoginForm'; export { default as PasswordChangeForm } from './PasswordChangeForm/PasswordChangeForm'; diff --git a/src/translations/en.json b/src/translations/en.json index ec37bd63..2035811a 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -256,6 +256,9 @@ "FieldReviewRating.star3": "OK - 3 stars", "FieldReviewRating.star4": "Good - 4 stars", "FieldReviewRating.star5": "Awesome - 5 stars", + "FilterForm.cancel": "Cancel", + "FilterForm.clear": "Clear", + "FilterForm.submit": "Apply", "Footer.copyright": "© Sharetribe", "Footer.goToFacebook": "Go to Facebook page", "Footer.goToInstagram": "Go to Instagram page", From b928c95a3b72b05f3c4830b68410b86076b0505a Mon Sep 17 00:00:00 2001 From: Jenni Nurmi Date: Mon, 12 Nov 2018 11:17:14 +0200 Subject: [PATCH 02/20] Create FilterPlain component --- src/components/FilterPlain/FilterPlain.css | 66 +++++++++++ .../FilterPlain/FilterPlain.example.js | 25 ++++ src/components/FilterPlain/FilterPlain.js | 109 ++++++++++++++++++ src/components/index.js | 1 + src/examples.js | 2 + src/translations/en.json | 1 + 6 files changed, 204 insertions(+) create mode 100644 src/components/FilterPlain/FilterPlain.css create mode 100644 src/components/FilterPlain/FilterPlain.example.js create mode 100644 src/components/FilterPlain/FilterPlain.js diff --git a/src/components/FilterPlain/FilterPlain.css b/src/components/FilterPlain/FilterPlain.css new file mode 100644 index 00000000..89d8888c --- /dev/null +++ b/src/components/FilterPlain/FilterPlain.css @@ -0,0 +1,66 @@ +@import '../../marketplace.css'; + +.root { + position: relative; + padding-top: 24px; + padding-bottom: 17px; + border-bottom: 1px solid var(--matterColorNegative); +} + +.filterLabel, +.filterLabelSelected { + @apply --marketplaceH3FontStyles; + + /* Baseline adjustment for label text */ + margin-top: 0; + margin-bottom: 12px; + padding: 4px 0 2px 0; +} + +.filterLabel { + color: var(--matterColorDark); +} + +.filterLabelSelected { + color: var(--marketplaceColor); +} + +.labelButton { + /* Override button styles */ + outline: none; + text-align: left; + border: none; + padding: 0; + cursor: pointer; +} + +.clearButton { + @apply --marketplaceH5FontStyles; + font-weight: var(--fontWeightMedium); + color: var(--matterColorAnti); + + /* Layout */ + display: inline; + float: right; + margin-top: 6px; + padding: 0; + + /* Override button styles */ + outline: none; + text-align: left; + border: none; + + &:focus, + &:hover { + color: var(--matterColor); + } +} + +.plain { + width: 100%; + display: none; +} + +.isOpen { + display: block; +} diff --git a/src/components/FilterPlain/FilterPlain.example.js b/src/components/FilterPlain/FilterPlain.example.js new file mode 100644 index 00000000..eb82709d --- /dev/null +++ b/src/components/FilterPlain/FilterPlain.example.js @@ -0,0 +1,25 @@ +import React from 'react'; +import { FieldTextInput } from '../../components'; +import FilterPlain from './FilterPlain'; + +const id = 'FilterPlainExample'; +const field = ; + +export const FilterPlainExample = { + component: FilterPlain, + props: { + id, + liveEdit: true, + showAsPopup: false, + isSelected: false, + urlParam: 'example', + initialValues: {}, + contentPlacementOffset: -14, + onSubmit: (urlParam, values) => { + console.log(`onSubmit with urlParam: ${urlParam} and values: ${JSON.stringify(values)}`); + }, + label: 'Example label', + children: field, + }, + group: 'misc', +}; diff --git a/src/components/FilterPlain/FilterPlain.js b/src/components/FilterPlain/FilterPlain.js new file mode 100644 index 00000000..da36e9a4 --- /dev/null +++ b/src/components/FilterPlain/FilterPlain.js @@ -0,0 +1,109 @@ +import React, { Component } from 'react'; +import { bool, func, node, string } from 'prop-types'; +import classNames from 'classnames'; +import { injectIntl, intlShape, FormattedMessage } from 'react-intl'; + +import { FilterForm } from '../../forms'; +import css from './FilterPlain.css'; + +class FilterPlainComponent extends Component { + constructor(props) { + super(props); + this.state = { isOpen: true }; + + this.handleChange = this.handleChange.bind(this); + this.handleClear = this.handleClear.bind(this); + this.toggleIsOpen = this.toggleIsOpen.bind(this); + } + + handleChange(values) { + const { onSubmit, urlParam } = this.props; + onSubmit(urlParam, values); + } + + handleClear() { + const { onSubmit, onClear, urlParam } = this.props; + + if (onClear) { + onClear(); + } + + onSubmit(urlParam, null); + } + + toggleIsOpen() { + this.setState(prevState => ({ isOpen: !prevState.isOpen })); + } + + render() { + const { + rootClassName, + className, + plainClassName, + id, + label, + isSelected, + children, + initialValues, + contentPlacementOffset, + } = this.props; + const classes = classNames(rootClassName || css.root, className); + + const labelClass = isSelected ? css.filterLabelSelected : css.filterLabel; + + return ( +
+
+ + +
+
{ + this.filterContent = node; + }} + > + + {children} + +
+
+ ); + } +} + +FilterPlainComponent.defaultProps = { + rootClassName: null, + className: null, + plainClassName: null, + initialValues: null, +}; + +FilterPlainComponent.propTypes = { + rootClassName: string, + className: string, + plainClassName: string, + id: string.isRequired, + onSubmit: func.isRequired, + label: node.isRequired, + isSelected: bool.isRequired, + children: node.isRequired, + + // form injectIntl + intl: intlShape.isRequired, +}; + +const FilterPlain = injectIntl(FilterPlainComponent); + +export default FilterPlain; diff --git a/src/components/index.js b/src/components/index.js index 167705b3..719e4f40 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -28,6 +28,7 @@ export { default as FieldPhoneNumberInput } from './FieldPhoneNumberInput/FieldP 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 Footer } from './Footer/Footer'; export { default as Form } from './Form/Form'; export { default as IconAdd } from './IconAdd/IconAdd'; diff --git a/src/examples.js b/src/examples.js index e567c349..d34d483e 100644 --- a/src/examples.js +++ b/src/examples.js @@ -19,6 +19,7 @@ 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 Footer from './components/Footer/Footer.example'; import * as IconAdd from './components/IconAdd/IconAdd.example'; import * as IconBannedUser from './components/IconBannedUser/IconBannedUser.example'; @@ -120,6 +121,7 @@ export { FieldSelect, FieldTextInput, FilterForm, + FilterPlain, Footer, IconAdd, IconBannedUser, diff --git a/src/translations/en.json b/src/translations/en.json index 2035811a..290b3135 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -259,6 +259,7 @@ "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", From 0870f01b13c7423858fa79842d093bf221651964 Mon Sep 17 00:00:00 2001 From: Jenni Nurmi Date: Mon, 12 Nov 2018 11:18:16 +0200 Subject: [PATCH 03/20] Create FilterPopup component --- src/components/FilterPopup/FilterPopup.css | 77 +++++++ .../FilterPopup/FilterPopup.example.js | 25 +++ src/components/FilterPopup/FilterPopup.js | 188 ++++++++++++++++++ src/components/index.js | 1 + src/examples.js | 2 + 5 files changed, 293 insertions(+) create mode 100644 src/components/FilterPopup/FilterPopup.css create mode 100644 src/components/FilterPopup/FilterPopup.example.js create mode 100644 src/components/FilterPopup/FilterPopup.js diff --git a/src/components/FilterPopup/FilterPopup.css b/src/components/FilterPopup/FilterPopup.css new file mode 100644 index 00000000..f3d73f47 --- /dev/null +++ b/src/components/FilterPopup/FilterPopup.css @@ -0,0 +1,77 @@ +@import '../../marketplace.css'; + +.root { + position: relative; + display: inline-block; +} + +.label { + @apply --marketplaceButtonStylesSecondary; + @apply --marketplaceSearchFilterLabelFontStyles; + + padding: 9px 16px 10px 16px; + width: auto; + height: auto; + min-height: 0; + border-radius: 4px; + + &:focus { + outline: none; + background-color: var(--matterColorLight); + border-color: transparent; + text-decoration: none; + box-shadow: var(--boxShadowFilterButton); + } +} + +.labelSelected { + @apply --marketplaceButtonStyles; + @apply --marketplaceSearchFilterLabelFontStyles; + font-weight: var(--fontWeightSemiBold); + + padding: 9px 16px 10px 16px; + width: auto; + height: auto; + min-height: 0; + border-radius: 4px; + border: 1px solid var(--marketplaceColor); + + &:hover, + &:focus { + border: 1px solid var(--marketplaceColorDark); + } +} + +.popup { + /* By default hide the content */ + display: block; + visibility: hidden; + opacity: 0; + pointer-events: none; + + /* Position */ + position: absolute; + z-index: var(--zIndexPopup); + + /* Layout */ + min-width: 300px; + margin-top: 7px; + background-color: var(--matterColorLight); + + /* Borders */ + border-top: 1px solid var(--matterColorNegative); + box-shadow: var(--boxShadowPopup); + border-radius: 4px; + transition: var(--transitionStyleButton); +} + +.popupSize { + padding: 15px 30px 17px 30px; +} + +.isOpen { + display: block; + visibility: visible; + opacity: 1; + pointer-events: auto; +} diff --git a/src/components/FilterPopup/FilterPopup.example.js b/src/components/FilterPopup/FilterPopup.example.js new file mode 100644 index 00000000..7c24ab37 --- /dev/null +++ b/src/components/FilterPopup/FilterPopup.example.js @@ -0,0 +1,25 @@ +import React from 'react'; +import { FieldTextInput } from '../../components'; +import FilterPopup from './FilterPopup'; + +const id = 'FilterPopupExample'; +const field = ; + +export const FilterPopupExample = { + component: FilterPopup, + props: { + id, + liveEdit: false, + showAsPopup: true, + urlParam: 'example', + initialValues: {}, + isSelected: false, + contentPlacementOffset: -14, + onSubmit: (urlParam, values) => { + console.log(`onSubmit with urlParam: ${urlParam} and values: ${JSON.stringify(values)}`); + }, + label: 'Example label', + children: field, + }, + group: 'misc', +}; diff --git a/src/components/FilterPopup/FilterPopup.js b/src/components/FilterPopup/FilterPopup.js new file mode 100644 index 00000000..3ba087c0 --- /dev/null +++ b/src/components/FilterPopup/FilterPopup.js @@ -0,0 +1,188 @@ +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 { 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(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 (event.relatedTarget && !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 + // unless there's no space in which case it is rendered + // to the left + const distanceToRight = window.innerWidth - this.filter.getBoundingClientRect().right; + const labelWidth = this.filter.offsetWidth; + const contentWidth = this.filterContent.offsetWidth; + const contentWidthBiggerThanLabel = contentWidth - labelWidth; + const renderToRight = distanceToRight > contentWidthBiggerThanLabel; + const contentPlacementOffset = this.props.contentPlacementOffset; + + const offset = renderToRight + ? { left: contentPlacementOffset } + : { right: contentPlacementOffset }; + // set a min-width if the content is narrower than the label + const minWidth = contentWidth < labelWidth ? { minWidth: labelWidth } : null; + + return { ...offset, ...minWidth }; + } + return {}; + } + + render() { + const { + rootClassName, + className, + popupClassName, + id, + label, + isSelected, + children, + initialValues, + contentPlacementOffset, + } = this.props; + + const classes = classNames(rootClassName || css.root, className); + const popupClasses = classNames(css.popup, { [css.isOpen]: this.state.isOpen }); + const popupSizeClasses = popupClassName || css.popupSize; + const labelStyles = isSelected ? css.labelSelected : css.label; + const contentStyle = this.positionStyleForContent(); + + return ( +
{ + this.filter = node; + }} + > + +
{ + this.filterContent = node; + }} + style={contentStyle} + > + {this.state.isOpen ? ( + + {children} + + ) : null} +
+
+ ); + } +} + +FilterPopup.defaultProps = { + rootClassName: null, + className: null, + popupClassName: null, + initialValues: null, + contentPlacementOffset: 0, + liveEdit: false, + label: null, +}; + +FilterPopup.propTypes = { + rootClassName: string, + className: string, + popupClassName: string, + id: string.isRequired, + urlParam: string.isRequired, + onSubmit: func.isRequired, + initialValues: object, + contentPlacementOffset: number, + label: string.isRequired, + isSelected: bool.isRequired, + children: node.isRequired, + + // form injectIntl + intl: intlShape.isRequired, +}; + +export default injectIntl(FilterPopup); diff --git a/src/components/index.js b/src/components/index.js index 719e4f40..f8bbbf84 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -29,6 +29,7 @@ export { default as FieldReviewRating } from './FieldReviewRating/FieldReviewRat 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'; diff --git a/src/examples.js b/src/examples.js index d34d483e..4cf2a4cf 100644 --- a/src/examples.js +++ b/src/examples.js @@ -20,6 +20,7 @@ import * as FieldReviewRating from './components/FieldReviewRating/FieldReviewRa 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'; @@ -122,6 +123,7 @@ export { FieldTextInput, FilterForm, FilterPlain, + FilterPopup, Footer, IconAdd, IconBannedUser, From 69631bf5fc64e2b8c5ab8219fd49dd926e9d62e7 Mon Sep 17 00:00:00 2001 From: Jenni Nurmi Date: Mon, 12 Nov 2018 14:23:08 +0200 Subject: [PATCH 04/20] Create BookingDateRangeFilter component --- .../BookingDateRangeFilter.css | 5 + .../BookingDateRangeFilter.example.js | 73 +++++++++ .../BookingDateRangeFilter.js | 153 ++++++++++++++++++ .../BookingDateRangeFilter.test.js | 43 +++++ .../BookingDateRangeFilter.test.js.snap | 54 +++++++ src/components/index.js | 1 + src/examples.js | 2 + src/translations/en.json | 4 + 8 files changed, 335 insertions(+) create mode 100644 src/components/BookingDateRangeFilter/BookingDateRangeFilter.css create mode 100644 src/components/BookingDateRangeFilter/BookingDateRangeFilter.example.js create mode 100644 src/components/BookingDateRangeFilter/BookingDateRangeFilter.js create mode 100644 src/components/BookingDateRangeFilter/BookingDateRangeFilter.test.js create mode 100644 src/components/BookingDateRangeFilter/__snapshots__/BookingDateRangeFilter.test.js.snap diff --git a/src/components/BookingDateRangeFilter/BookingDateRangeFilter.css b/src/components/BookingDateRangeFilter/BookingDateRangeFilter.css new file mode 100644 index 00000000..0268782e --- /dev/null +++ b/src/components/BookingDateRangeFilter/BookingDateRangeFilter.css @@ -0,0 +1,5 @@ +@import '../../marketplace.css'; + +.popupSize { + padding: 0 10px 7px 10px; +} diff --git a/src/components/BookingDateRangeFilter/BookingDateRangeFilter.example.js b/src/components/BookingDateRangeFilter/BookingDateRangeFilter.example.js new file mode 100644 index 00000000..ae112118 --- /dev/null +++ b/src/components/BookingDateRangeFilter/BookingDateRangeFilter.example.js @@ -0,0 +1,73 @@ +import React from 'react'; +import { withRouter } from 'react-router-dom'; +import { stringify, parse } from '../../util/urlHelpers'; +import { parseDateFromISO8601, stringifyDateToISO8601 } from '../../util/dates'; +import BookingDateRangeFilter from './BookingDateRangeFilter'; + +const URL_PARAM = 'dates'; + +const handleSubmit = (urlParam, values, history) => { + const hasDates = values && values.dates; + const { startDate, endDate } = hasDates ? values.dates : {}; + + const start = startDate ? stringifyDateToISO8601(startDate) : null; + const end = endDate ? stringifyDateToISO8601(endDate) : null; + + const queryParams = + start != null && end != null ? `?${stringify({ [urlParam]: [start, end].join(',') })}` : ''; + history.push(`${window.location.pathname}${queryParams}`); +}; + +const BookingDateRangeFilterWrapper = withRouter(props => { + const { history, location } = props; + + const params = parse(location.search); + const dates = params[URL_PARAM]; + const rawValuesFromParams = !!dates ? dates.split(',') : []; + const valuesFromParams = rawValuesFromParams.map(v => parseDateFromISO8601(v)); + const initialValues = + !!dates && valuesFromParams.length === 2 + ? { + dates: { startDate: valuesFromParams[0], endDate: valuesFromParams[1] }, + } + : { dates: null }; + + return ( + { + console.log('Submit BookingDateRangeFilter with (unformatted) values:', values); + handleSubmit(urlParam, values, history); + }} + /> + ); +}); + +export const BookingDateRangeFilterPopup = { + component: BookingDateRangeFilterWrapper, + props: { + id: 'BookingDateRangeFilterPopupExample', + urlParam: URL_PARAM, + liveEdit: false, + showAsPopup: true, + contentPlacementOffset: -14, + // initialValues: handled inside wrapper + // onSubmit: handled inside wrapper + }, + group: 'misc', +}; + +export const BookingDateRangeFilterPlain = { + component: BookingDateRangeFilterWrapper, + props: { + id: 'BookingDateRangeFilterPlainExample', + urlParam: URL_PARAM, + liveEdit: true, + showAsPopup: false, + contentPlacementOffset: -14, + // initialValues: handled inside wrapper + // onSubmit: handled inside wrapper + }, + group: 'misc', +}; diff --git a/src/components/BookingDateRangeFilter/BookingDateRangeFilter.js b/src/components/BookingDateRangeFilter/BookingDateRangeFilter.js new file mode 100644 index 00000000..b1d39683 --- /dev/null +++ b/src/components/BookingDateRangeFilter/BookingDateRangeFilter.js @@ -0,0 +1,153 @@ +import React, { Component } from 'react'; +import { bool, func, number, object, string } from 'prop-types'; +import { injectIntl, intlShape } from 'react-intl'; + +import { FieldDateRangeController, FilterPopup, FilterPlain } from '../../components'; +import css from './BookingDateRangeFilter.css'; + +export class BookingDateRangeFilterComponent extends Component { + constructor(props) { + super(props); + + this.popupControllerRef = null; + this.plainControllerRef = null; + } + + render() { + const { + className, + rootClassName, + showAsPopup, + initialValues: initialValuesRaw, + id, + contentPlacementOffset, + onSubmit, + urlParam, + intl, + ...rest + } = this.props; + + const isSelected = !!initialValuesRaw && !!initialValuesRaw.dates; + const initialValues = isSelected ? initialValuesRaw : { dates: null }; + + const startDate = isSelected ? initialValues.dates.startDate : null; + const endDate = isSelected ? initialValues.dates.endDate : null; + + const format = { + month: 'short', + day: 'numeric', + }; + + const formattedStartDate = isSelected ? intl.formatDate(startDate, format) : null; + const formattedEndDate = isSelected ? intl.formatDate(endDate, format) : null; + + const labelForPlain = isSelected + ? intl.formatMessage( + { id: 'BookingDateRangeFilter.labelSelectedPlain' }, + { + dates: `${formattedStartDate} - ${formattedEndDate}`, + } + ) + : intl.formatMessage({ id: 'BookingDateRangeFilter.labelPlain' }); + + const labelForPopup = isSelected + ? intl.formatMessage( + { id: 'BookingDateRangeFilter.labelSelectedPopup' }, + { + dates: `${formattedStartDate} - ${formattedEndDate}`, + } + ) + : intl.formatMessage({ id: 'BookingDateRangeFilter.labelPopup' }); + + const onClearPopupMaybe = + this.popupControllerRef && this.popupControllerRef.onReset + ? { onClear: () => this.popupControllerRef.onReset(null, null) } + : {}; + + const onCancelPopupMaybe = + this.popupControllerRef && this.popupControllerRef.onReset + ? { onCancel: () => this.popupControllerRef.onReset(startDate, endDate) } + : {}; + + const onClearPlainMaybe = + this.plainControllerRef && this.plainControllerRef.onReset + ? { onClear: () => this.plainControllerRef.onReset(null, null) } + : {}; + + return showAsPopup ? ( + + { + this.popupControllerRef = node; + }} + /> + + ) : ( + + { + this.plainControllerRef = node; + }} + /> + + ); + } +} + +BookingDateRangeFilterComponent.defaultProps = { + rootClassName: null, + className: null, + showAsPopup: true, + liveEdit: false, + initialValues: null, + contentPlacementOffset: 0, +}; + +BookingDateRangeFilterComponent.propTypes = { + rootClassName: string, + className: string, + id: string.isRequired, + showAsPopup: bool, + liveEdit: bool, + urlParam: string.isRequired, + onSubmit: func.isRequired, + initialValues: object, + contentPlacementOffset: number, + + // form injectIntl + intl: intlShape.isRequired, +}; + +const BookingDateRangeFilter = injectIntl(BookingDateRangeFilterComponent); + +export default BookingDateRangeFilter; diff --git a/src/components/BookingDateRangeFilter/BookingDateRangeFilter.test.js b/src/components/BookingDateRangeFilter/BookingDateRangeFilter.test.js new file mode 100644 index 00000000..a12753ad --- /dev/null +++ b/src/components/BookingDateRangeFilter/BookingDateRangeFilter.test.js @@ -0,0 +1,43 @@ +import React from 'react'; + +// react-dates needs to be initialized before using any react-dates component +// Since this is currently only component using react-dates we can do it here +// https://github.com/airbnb/react-dates#initialize +import 'react-dates/initialize'; +import { renderShallow } from '../../util/test-helpers'; +import { fakeIntl } from '../../util/test-data'; +import { BookingDateRangeFilterComponent } from './BookingDateRangeFilter'; + +describe('BookingDateRangeFilter', () => { + it('matches popup snapshot', () => { + const tree = renderShallow( + null} + intl={fakeIntl} + /> + ); + expect(tree).toMatchSnapshot(); + }); + + it('matches plain snapshot', () => { + const tree = renderShallow( + null} + intl={fakeIntl} + /> + ); + expect(tree).toMatchSnapshot(); + }); +}); diff --git a/src/components/BookingDateRangeFilter/__snapshots__/BookingDateRangeFilter.test.js.snap b/src/components/BookingDateRangeFilter/__snapshots__/BookingDateRangeFilter.test.js.snap new file mode 100644 index 00000000..fa20fb44 --- /dev/null +++ b/src/components/BookingDateRangeFilter/__snapshots__/BookingDateRangeFilter.test.js.snap @@ -0,0 +1,54 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`BookingDateRangeFilter matches plain snapshot 1`] = ` + + + +`; + +exports[`BookingDateRangeFilter matches popup snapshot 1`] = ` + + + +`; diff --git a/src/components/index.js b/src/components/index.js index f8bbbf84..ba2aae0b 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -2,6 +2,7 @@ export { default as ActivityFeed } from './ActivityFeed/ActivityFeed'; export { default as AddImages } from './AddImages/AddImages'; export { default as Avatar, AvatarMedium, AvatarLarge } from './Avatar/Avatar'; export { default as BookingBreakdown } from './BookingBreakdown/BookingBreakdown'; +export { default as BookingDateRangeFilter } from './BookingDateRangeFilter/BookingDateRangeFilter'; export { default as Button, PrimaryButton, SecondaryButton, InlineTextButton } from './Button/Button'; export { default as BookingPanel } from './BookingPanel/BookingPanel'; export { default as CookieConsent } from './CookieConsent/CookieConsent'; diff --git a/src/examples.js b/src/examples.js index 4cf2a4cf..1961a56d 100644 --- a/src/examples.js +++ b/src/examples.js @@ -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'; @@ -94,6 +95,7 @@ export { AddImages, Avatar, BookingBreakdown, + BookingDateRangeFilter, BookingDatesForm, BookingPanel, Button, diff --git a/src/translations/en.json b/src/translations/en.json index 290b3135..f0d3c3a7 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -67,6 +67,10 @@ "BookingDatesForm.requiredDate": "Oops, make sure your date is correct!", "BookingDatesForm.timeSlotsError": "Loading listing availability failed. Please refresh the page.", "BookingDatesForm.youWontBeChargedInfo": "You won't be charged yet", + "BookingDateRangeFilter.labelPlain": "Dates", + "BookingDateRangeFilter.labelPopup": "Dates", + "BookingDateRangeFilter.labelSelectedPlain": "{dates}", + "BookingDateRangeFilter.labelSelectedPopup": "{dates}", "BookingPanel.closedListingButtonText": "Sorry, this listing has been closed.", "BookingPanel.ctaButtonMessage": "Request to book", "BookingPanel.hostedBy": "Hosted by {name}", From f3b66239a6d49beeb8d76da32855f5ace24543a3 Mon Sep 17 00:00:00 2001 From: Jenni Nurmi Date: Mon, 19 Nov 2018 15:08:20 +0200 Subject: [PATCH 05/20] Create DateRangeController component --- .../DateRangeController.css | 218 ++++++++++++++++++ .../DateRangeController.example.js | 7 + .../DateRangeController.js | 190 +++++++++++++++ 3 files changed, 415 insertions(+) create mode 100644 src/components/FieldDateRangeController/DateRangeController.css create mode 100644 src/components/FieldDateRangeController/DateRangeController.example.js create mode 100644 src/components/FieldDateRangeController/DateRangeController.js diff --git a/src/components/FieldDateRangeController/DateRangeController.css b/src/components/FieldDateRangeController/DateRangeController.css new file mode 100644 index 00000000..02a02136 --- /dev/null +++ b/src/components/FieldDateRangeController/DateRangeController.css @@ -0,0 +1,218 @@ +@import '../../marketplace.css'; + +:root { + /* + These variables are available in global scope through ":root" + element ( tag). Variables with the same names are going to + overwrite each other if CSS Properties' (PostCSS plugin) + configuration "preserve: true" is used - meaning that variables + are left to CSS bundle. We are planning to enable it in the future + since browsers support CSS Properties already. + */ + + --DateRangeController_selectionHeight: 36px; +} + +.inputRoot { + /* + Calendar component using react-dates has automatically padding so + negative margin to left and right is needed for element to fit on smaller screens. + */ + + margin: 0px -20px; + + @media (--viewportMedium) { + margin: 0; + } + + & :global(.CalendarMonthGrid) { + background-color: transparent; + } + + & :global(.DayPicker__horizontal) { + margin: 0 auto; + box-shadow: none; + background-color: transparent; + } + + & :global(.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; + + @media (--viewportMedium) { + margin-top: 0; + margin-bottom: 0; + } + } + + & :global(.DayPickerNavigation_button) { + color: var(--matterColor); + border: 0; + top: 26px; + } + + & :global(.CalendarDay__default) { + border: 0; + padding: 0; + width: 100%; + height: 100%; + background: transparent; + } + + & :global(.CalendarDay) { + @apply --marketplaceH4FontStyles; + color: var(--matterColor); + border: 0; + margin-top: 0; + margin-bottom: 0; + + @media (--viewportMedium) { + margin-top: 0; + margin-bottom: 0; + } + } + + /* Add an underline for '.renderedDay' */ + & :global(.CalendarDay__today .renderedDay) { + display: flex; + justify-content: center; + align-items: center; + width: 100%; + height: var(--DateRangeController_selectionHeight); + background-image: url("data:image/svg+xml;utf8,"); + background-position: center 28px; + } + + & :global(.CalendarDay__today.CalendarDay__selected .renderedDay) { + background-image: url("data:image/svg+xml;utf8,"); + } + + & :global(.CalendarDay:hover .renderedDay) { + display: flex; + justify-content: center; + align-items: center; + width: 100%; + height: var(--DateRangeController_selectionHeight); + background-color: var(--matterColorNegative); + color: var(--matterColor); + } + + /* Remove default bg-color and use our extra span instead '.renderedDay' */ + & :global(.CalendarDay__hovered_span), + & :global(.CalendarDay__selected_span) { + background-image: transparent; + background-color: transparent; + } + & :global(.CalendarDay__hovered_span .renderedDay), + & :global(.CalendarDay__selected_span .renderedDay), + & :global(.CalendarDay__hovered_span:hover .renderedDay), + & :global(.CalendarDay__selected_span:hover .renderedDay) { + display: flex; + justify-content: center; + align-items: center; + width: 100%; + height: var(--DateRangeController_selectionHeight); + background-color: var(--successColor); + color: var(--matterColorLight); + } + + /* Remove default bg-color and use our extra span instead '.renderedDay' */ + & :global(.CalendarDay__selected_start) { + background-color: transparent; + background-image: none; + } + & :global(.CalendarDay__selected_start .renderedDay) { + display: flex; + justify-content: center; + align-items: center; + width: 100%; + height: var(--DateRangeController_selectionHeight); + background-color: var(--successColor); + color: var(--matterColorLight); + border-top-left-radius: calc(var(--DateRangeController_selectionHeight) / 2); + border-bottom-left-radius: calc(var(--DateRangeController_selectionHeight) / 2); + } + + /* Remove default bg-color and use our extra span instead '.renderedDay' */ + & :global(.CalendarDay__selected_end) { + background-color: transparent; + } + & :global(.CalendarDay__selected_end .renderedDay) { + display: flex; + justify-content: center; + align-items: center; + width: 100%; + height: var(--DateRangeController_selectionHeight); + background-color: var(--successColor); + color: var(--matterColorLight); + border-top-right-radius: calc(var(--DateRangeController_selectionHeight) / 2); + border-bottom-right-radius: calc(var(--DateRangeController_selectionHeight) / 2); + } + & :global(.CalendarDay:hover.CalendarDay__selected_start .renderedDay), + & :global(.CalendarDay:hover.CalendarDay__selected_span .renderedDay), + & :global(.CalendarDay:hover.CalendarDay__selected_end .renderedDay) { + display: flex; + justify-content: center; + align-items: center; + width: 100%; + height: var(--DateRangeController_selectionHeight); + background-color: var(--successColor); + color: var(--matterColorLight); + } + + & :global(.CalendarDay__selected_span .renderedDay) { + display: flex; + justify-content: center; + align-items: center; + width: 100%; + height: var(--DateRangeController_selectionHeight); + background-color: var(--successColor); + color: var(--matterColorLight); + transition: all 0.2s ease-out; + } + + /* Remove default bg-color and use our extra span instead '.renderedDay' */ + & :global(.CalendarDay__blocked_out_of_range .renderedDay), + & :global(.CalendarDay__blocked_out_of_range:hover .renderedDay) { + background-color: transparent; + color: var(--matterColorAnti); + text-decoration: line-through; + border: 0; + } +} + +.arrowIcon { + stroke: var(--marketplaceColor); + fill: var(--marketplaceColor); +} diff --git a/src/components/FieldDateRangeController/DateRangeController.example.js b/src/components/FieldDateRangeController/DateRangeController.example.js new file mode 100644 index 00000000..9c6c728e --- /dev/null +++ b/src/components/FieldDateRangeController/DateRangeController.example.js @@ -0,0 +1,7 @@ +import DateRangeController from './DateRangeController'; + +export const DateRangeControllerExample = { + component: DateRangeController, + props: {}, + group: 'custom inputs', +}; diff --git a/src/components/FieldDateRangeController/DateRangeController.js b/src/components/FieldDateRangeController/DateRangeController.js new file mode 100644 index 00000000..92e86d73 --- /dev/null +++ b/src/components/FieldDateRangeController/DateRangeController.js @@ -0,0 +1,190 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { + DayPickerRangeController, + isInclusivelyAfterDay, + isInclusivelyBeforeDay, +} from 'react-dates'; +import classNames from 'classnames'; +import moment from 'moment'; +import { START_DATE } from '../../util/dates'; +import config from '../../config'; + +import { IconArrowHead } from '../../components'; +import css from './DateRangeController.css'; + +export const HORIZONTAL_ORIENTATION = 'horizontal'; +export const ANCHOR_LEFT = 'left'; + +// IconArrowHead component might not be defined if exposed directly to the file. +// This component is called before IconArrowHead component in components/index.js +const PrevIcon = props => ( + +); +const NextIcon = props => ( + +); + +const defaultProps = { + startDateOffset: undefined, + endDateOffset: undefined, + + // calendar presentation and interaction related props + + orientation: HORIZONTAL_ORIENTATION, + verticalHeight: undefined, + withPortal: false, + isRTL: false, + initialVisibleMonth: null, + firstDayOfWeek: config.i18n.firstDayOfWeek, + numberOfMonths: 1, + daySize: 38, + keepOpenOnDateSelect: false, + renderCalendarInfo: null, + hideKeyboardShortcutsPanel: true, + + // navigation related props + navPrev: , + navNext: , + onPrevMonthClick() {}, + onNextMonthClick() {}, + transitionDuration: 200, // milliseconds between next month changes etc. + + renderCalendarDay: undefined, // If undefined, renders react-dates/lib/components/CalendarDay + // day presentation and interaction related props + renderDayContents: day => { + return {day.format('D')}; + }, + minimumNights: 1, + enableOutsideDays: false, + isDayBlocked: () => false, + + // outside range -><- today ... today+available days -1 -><- outside range + isOutsideRange: day => { + const endOfRange = config.dayCountAvailableForBooking - 1; + return ( + !isInclusivelyAfterDay(day, moment()) || + !isInclusivelyBeforeDay(day, moment().add(endOfRange, 'days')) + ); + }, + isDayHighlighted: () => {}, + + // Internationalization props + // Multilocale support can be achieved with displayFormat like moment.localeData.longDateFormat('L') + // https://momentjs.com/ + // displayFormat: 'ddd, MMM D', + monthFormat: 'MMMM YYYY', + weekDayFormat: 'dd', + phrases: {}, // Add overwrites to default phrases used by react-dates +}; + +class DateRangeController extends Component { + constructor(props) { + super(props); + + this.state = { + startDate: props.value && props.value.startDate ? moment(props.value.startDate) : null, + endDate: props.value && props.value.endDate ? moment(props.value.endDate) : null, + focusedInput: START_DATE, + }; + + this.onDatesChange = this.onDatesChange.bind(this); + this.onFocusChange = this.onFocusChange.bind(this); + this.onReset = this.onReset.bind(this); + } + + onDatesChange(values) { + const { startDate, endDate } = values; + + const start = startDate ? startDate.toDate() : null; + const end = endDate ? endDate.toDate() : null; + + this.setState({ startDate, endDate }); + + if (startDate && endDate) { + this.props.onChange({ startDate: start, endDate: end }); + } + } + + onFocusChange(focusedInput) { + this.setState({ + // Force the focusedInput to always be truthy so that dates are always selectable + focusedInput: !focusedInput ? START_DATE : focusedInput, + }); + } + + onReset(startDate, endDate) { + if (startDate && endDate) { + this.setState({ + startDate: moment(startDate), + endDate: moment(endDate), + focusedInput: START_DATE, + }); + } else { + this.setState({ + startDate: null, + endDate: null, + focusedInput: START_DATE, + }); + } + } + + render() { + // Removing Final Form field props: name, value, onChange, onFocus, meta, children, render + const { + rootClassName, + className, + name, + value, + onChange, + onFocus, + meta, + children, + render, + ...controllerProps + } = this.props; + + const classes = classNames(rootClassName || css.inputRoot, className); + + const startDateFromState = this.state.startDate; + const endDateFromState = this.state.endDate; + + const startDateFromForm = value && value.startDate ? moment(value.startDate) : null; + const endDateFromForm = value && value.endDate ? moment(value.endDate) : null; + + const isSelected = startDateFromState && endDateFromState; + + // Value given by Final Form reflects url params and is valid if both dates are set. + // If only one date is selected state should be used to get the correct date. + const startDate = isSelected ? startDateFromForm : startDateFromState; + const endDate = isSelected ? endDateFromForm : endDateFromState; + + return ( +
+ +
+ ); + } +} + +DateRangeController.defaultProps = { + rootClassName: null, + className: null, + ...defaultProps, +}; + +const { string } = PropTypes; + +DateRangeController.propTypes = { + rootClassName: string, + className: string, +}; + +export default DateRangeController; From 601f6cbc5ff2cb3d9baf4a61dbf23b40bf79b6df Mon Sep 17 00:00:00 2001 From: Jenni Nurmi Date: Mon, 19 Nov 2018 15:11:30 +0200 Subject: [PATCH 06/20] Create FieldDateRangeController component --- .../DateRangeController.css | 28 ++++++++++--- .../FieldDateRangeController.example.js | 40 +++++++++++++++++++ .../FieldDateRangeController.js | 25 ++++++++++++ .../FieldDateRangeInput/DateRangeInput.css | 2 +- src/components/index.js | 1 + src/examples.js | 2 + 6 files changed, 91 insertions(+), 7 deletions(-) create mode 100644 src/components/FieldDateRangeController/FieldDateRangeController.example.js create mode 100644 src/components/FieldDateRangeController/FieldDateRangeController.js diff --git a/src/components/FieldDateRangeController/DateRangeController.css b/src/components/FieldDateRangeController/DateRangeController.css index 02a02136..15193543 100644 --- a/src/components/FieldDateRangeController/DateRangeController.css +++ b/src/components/FieldDateRangeController/DateRangeController.css @@ -35,6 +35,28 @@ 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; @@ -76,12 +98,6 @@ } } - & :global(.DayPickerNavigation_button) { - color: var(--matterColor); - border: 0; - top: 26px; - } - & :global(.CalendarDay__default) { border: 0; padding: 0; diff --git a/src/components/FieldDateRangeController/FieldDateRangeController.example.js b/src/components/FieldDateRangeController/FieldDateRangeController.example.js new file mode 100644 index 00000000..a4a76209 --- /dev/null +++ b/src/components/FieldDateRangeController/FieldDateRangeController.example.js @@ -0,0 +1,40 @@ +import React from 'react'; +import { Form as FinalForm } from 'react-final-form'; + +import FieldDateRangeController from './FieldDateRangeController'; + +const FormComponent = props => ( + { + const { style, handleSubmit, onChange, dirty } = formRenderProps; + + const handleChange = dirty ? onChange : () => null; + return ( +
{ + e.preventDefault(); + handleSubmit(e); + }} + > + + + ); + }} + /> +); + +export const DateRangeControllerExample = { + component: FormComponent, + props: { + onChange: values => { + if (values) { + const { startDate, endDate } = values; + console.log('Changed to: ', startDate, endDate); + } + }, + onSubmit: () => null, + }, + group: 'custom inputs', +}; diff --git a/src/components/FieldDateRangeController/FieldDateRangeController.js b/src/components/FieldDateRangeController/FieldDateRangeController.js new file mode 100644 index 00000000..e0652517 --- /dev/null +++ b/src/components/FieldDateRangeController/FieldDateRangeController.js @@ -0,0 +1,25 @@ +import React from 'react'; +import { string } from 'prop-types'; +import { Field } from 'react-final-form'; +import DateRangeController from './DateRangeController'; + +const component = props => { + const { input, controllerRef, ...rest } = props; + return ; +}; + +const FieldDateRangeController = props => { + return ; +}; + +FieldDateRangeController.defaultProps = { + rootClassName: null, + className: null, +}; + +FieldDateRangeController.propTypes = { + rootClassName: string, + className: string, +}; + +export default FieldDateRangeController; diff --git a/src/components/FieldDateRangeInput/DateRangeInput.css b/src/components/FieldDateRangeInput/DateRangeInput.css index d3807836..2a6a6233 100644 --- a/src/components/FieldDateRangeInput/DateRangeInput.css +++ b/src/components/FieldDateRangeInput/DateRangeInput.css @@ -154,7 +154,7 @@ width: 100%; height: var(--DateRangeInput_selectionHeight); background-image: url("data:image/svg+xml;utf8,"); - background-position: center 28px; + background-position: center 34px; } /* Remove default bg-color and use our extra span instead '.renderedDay' */ diff --git a/src/components/index.js b/src/components/index.js index ba2aae0b..9f9bfb05 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -23,6 +23,7 @@ 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'; diff --git a/src/examples.js b/src/examples.js index 1961a56d..8a81b56e 100644 --- a/src/examples.js +++ b/src/examples.js @@ -13,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'; @@ -115,6 +116,7 @@ export { FieldCheckbox, FieldCheckboxGroup, FieldCurrencyInput, + FieldDateRangeController, FieldDateInput, FieldDateRangeInput, FieldPhoneNumberInput, From 6f7e6c2fc04ccd4d88a4be9b6ce263e36d3dfea5 Mon Sep 17 00:00:00 2001 From: Jenni Nurmi Date: Mon, 19 Nov 2018 15:16:13 +0200 Subject: [PATCH 07/20] Add help functions for ISO8601 date format --- src/util/dates.js | 25 +++++++++++++++++++++++++ src/util/dates.test.js | 25 ++++++++++++++++++++++++- 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/src/util/dates.js b/src/util/dates.js index a5cbdbf1..13bee7f1 100644 --- a/src/util/dates.js +++ b/src/util/dates.js @@ -169,3 +169,28 @@ 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'); +}; diff --git a/src/util/dates.test.js b/src/util/dates.test.js index 5cd3bacf..daab97c2 100644 --- a/src/util/dates.test.js +++ b/src/util/dates.test.js @@ -1,5 +1,13 @@ import { fakeIntl } from './test-data'; -import { isDate, isSameDate, nightsBetween, daysBetween, formatDate } from './dates'; +import { + isDate, + isSameDate, + nightsBetween, + daysBetween, + formatDate, + parseDateFromISO8601, + stringifyDateToISO8601, +} from './dates'; describe('date utils', () => { describe('isDate()', () => { @@ -87,4 +95,19 @@ describe('date utils', () => { expect(formatDate(fakeIntl, 'Today', d)).toEqual('2017-11-22, 13:51'); }); }); + + describe('parseDateFromISO8601()', () => { + it('should return date', () => { + const dateString = '2018-11-23'; + const date = new Date(2018, 10, 23); + expect(parseDateFromISO8601(dateString)).toEqual(date); + }); + }); + + describe('stringifyDateToISO8601()', () => { + it('should return string in YYYY-MM-DD format', () => { + const date = new Date(2018, 10, 23); + expect(stringifyDateToISO8601(date)).toEqual('2018-11-23'); + }); + }); }); From 27a49d1534dffd52542dd78566d35eb8c74cd9da Mon Sep 17 00:00:00 2001 From: Jenni Nurmi Date: Tue, 13 Nov 2018 11:05:57 +0200 Subject: [PATCH 08/20] Add DateRangeFilter to desktop and mobile versions --- src/components/SearchFilters/SearchFilters.js | 57 +++++++++++++++- .../SearchFiltersMobile.js | 67 +++++++++++++++++-- .../SearchPage/SearchPage.helpers.js | 7 +- src/containers/SearchPage/SearchPage.js | 9 ++- .../__snapshots__/SearchPage.test.js.snap | 6 ++ src/marketplace-custom-config.js | 5 ++ src/util/types.js | 13 +++- 7 files changed, 155 insertions(+), 9 deletions(-) diff --git a/src/components/SearchFilters/SearchFilters.js b/src/components/SearchFilters/SearchFilters.js index b49bc269..0891720c 100644 --- a/src/components/SearchFilters/SearchFilters.js +++ b/src/components/SearchFilters/SearchFilters.js @@ -6,8 +6,14 @@ import classNames from 'classnames'; import { withRouter } from 'react-router-dom'; import omit from 'lodash/omit'; -import { SelectSingleFilter, SelectMultipleFilter, PriceFilter } from '../../components'; +import { + BookingDateRangeFilter, + SelectSingleFilter, + SelectMultipleFilter, + PriceFilter, +} from '../../components'; import routeConfiguration from '../../routeConfiguration'; +import { parseDateFromISO8601, stringifyDateToISO8601 } from '../../util/dates'; import { createResourceLocatorString } from '../../util/routes'; import { propTypes } from '../../util/types'; import css from './SearchFilters.css'; @@ -38,6 +44,20 @@ const initialPriceRangeValue = (queryParams, paramName) => { : null; }; +const initialDateRangeValue = (queryParams, paramName) => { + const dates = queryParams[paramName]; + const rawValuesFromParams = !!dates ? dates.split(',') : []; + const valuesFromParams = rawValuesFromParams.map(v => parseDateFromISO8601(v)); + const initialValues = + !!dates && valuesFromParams.length === 2 + ? { + dates: { startDate: valuesFromParams[0], endDate: valuesFromParams[1] }, + } + : { dates: null }; + + return initialValues; +}; + const SearchFiltersComponent = props => { const { rootClassName, @@ -49,6 +69,7 @@ const SearchFiltersComponent = props => { categoryFilter, amenitiesFilter, priceFilter, + dateRangeFilter, isSearchFiltersPanelOpen, toggleSearchFiltersPanel, searchFiltersPanelSelectedCount, @@ -79,6 +100,10 @@ const SearchFiltersComponent = props => { ? initialPriceRangeValue(urlQueryParams, priceFilter.paramName) : null; + const initialDateRange = dateRangeFilter + ? initialDateRangeValue(urlQueryParams, dateRangeFilter.paramName) + : null; + const handleSelectOptions = (urlParam, options) => { const queryParams = options && options.length > 0 @@ -108,6 +133,20 @@ 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 ? ( { /> ) : null; + const dateRangeFilterElement = + dateRangeFilter && dateRangeFilter.config.active ? ( + + ) : null; + const toggleSearchFiltersPanelButtonClasses = isSearchFiltersPanelOpen || searchFiltersPanelSelectedCount > 0 ? css.searchFiltersPanelOpen @@ -167,6 +218,7 @@ const SearchFiltersComponent = props => { {categoryFilterElement} {amenitiesFilterElement} {priceFilterElement} + {dateRangeFilterElement} {toggleSearchFiltersPanelButton} @@ -200,6 +252,8 @@ SearchFiltersComponent.defaultProps = { searchingInProgress: false, categoryFilter: null, amenitiesFilter: null, + priceFilter: null, + dateRangeFilter: null, isSearchFiltersPanelOpen: false, toggleSearchFiltersPanel: null, searchFiltersPanelSelectedCount: 0, @@ -216,6 +270,7 @@ SearchFiltersComponent.propTypes = { categoriesFilter: propTypes.filterConfig, amenitiesFilter: propTypes.filterConfig, priceFilter: propTypes.filterConfig, + dateRangeFilter: propTypes.filterConfig, isSearchFiltersPanelOpen: bool, toggleSearchFiltersPanel: func, searchFiltersPanelSelectedCount: number, diff --git a/src/components/SearchFiltersMobile/SearchFiltersMobile.js b/src/components/SearchFiltersMobile/SearchFiltersMobile.js index a1cfb102..1f871a0c 100644 --- a/src/components/SearchFiltersMobile/SearchFiltersMobile.js +++ b/src/components/SearchFiltersMobile/SearchFiltersMobile.js @@ -6,6 +6,7 @@ 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, @@ -14,6 +15,7 @@ import { PriceFilter, SelectSingleFilterPlain, SelectMultipleFilterPlain, + 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; @@ -225,6 +260,20 @@ class SearchFiltersMobileComponent extends Component { /> ) : null; + const initialDateRange = this.initialDateRangeValue(dateRangeFilter.paramName); + + const dateRangeFilterElement = + dateRangeFilter && dateRangeFilter.config.active ? ( + + ) : null; + return (
@@ -253,11 +302,15 @@ class SearchFiltersMobileComponent extends Component {
-
- {categoryFilterElement} - {amenitiesFilterElement} - {priceFilterElement} -
+ {this.state.isFiltersOpenOnMobile ? ( +
+ {categoryFilterElement} + {amenitiesFilterElement} + {priceFilterElement} + {dateRangeFilterElement} +
+ ) : null} +
- { - this.filterContent = node; - }} - style={contentStyle} /> -
+ + ) : ( + + + ); } } @@ -157,7 +147,7 @@ SelectMultipleFilter.propTypes = { name: string.isRequired, urlParam: string.isRequired, label: string.isRequired, - onSelect: func.isRequired, + onSubmit: func.isRequired, options: array.isRequired, initialValues: arrayOf(string), contentPlacementOffset: number, diff --git a/src/translations/en.json b/src/translations/en.json index f0d3c3a7..d2d15955 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -689,9 +689,6 @@ "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", From d89aa64e576869799fdf238efa086a6cccc28387 Mon Sep 17 00:00:00 2001 From: Jenni Nurmi Date: Wed, 21 Nov 2018 11:55:42 +0200 Subject: [PATCH 11/20] Delete unused code related to SelectMultipleFilter --- .../SelectMultipleFilterPlain.css | 70 ----------- .../SelectMultipleFilterPlain.example.js | 73 ----------- .../SelectMultipleFilterPlain.js | 119 ------------------ src/components/index.js | 1 - src/examples.js | 2 - .../SelectMultipleFilterForm.css | 107 ---------------- .../SelectMultipleFilterForm.js | 99 --------------- .../SelectMultipleFilterPlainForm.js | 55 -------- src/forms/index.js | 2 - 9 files changed, 528 deletions(-) delete mode 100644 src/components/SelectMultipleFilterPlain/SelectMultipleFilterPlain.css delete mode 100644 src/components/SelectMultipleFilterPlain/SelectMultipleFilterPlain.example.js delete mode 100644 src/components/SelectMultipleFilterPlain/SelectMultipleFilterPlain.js delete mode 100644 src/forms/SelectMultipleFilterForm/SelectMultipleFilterForm.css delete mode 100644 src/forms/SelectMultipleFilterForm/SelectMultipleFilterForm.js delete mode 100644 src/forms/SelectMultipleFilterPlainForm/SelectMultipleFilterPlainForm.js diff --git a/src/components/SelectMultipleFilterPlain/SelectMultipleFilterPlain.css b/src/components/SelectMultipleFilterPlain/SelectMultipleFilterPlain.css deleted file mode 100644 index ae0ebbc3..00000000 --- a/src/components/SelectMultipleFilterPlain/SelectMultipleFilterPlain.css +++ /dev/null @@ -1,70 +0,0 @@ -@import '../../marketplace.css'; - -.root { - padding-top: 24px; - padding-bottom: 17px; - border-bottom: 1px solid var(--matterColorNegative); -} - -.filterLabel, -.filterLabelSelected { - @apply --marketplaceH3FontStyles; - - /* Baseline adjustment for label text */ - margin-top: 0; - margin-bottom: 12px; - padding: 4px 0 2px 0; -} - -.filterLabel { - color: var(--matterColorDark); -} - -.filterLabelSelected { - color: var(--marketplaceColor); -} - -.labelButton { - /* Override button styles */ - outline: none; - text-align: left; - border: none; - padding: 0; - cursor: pointer; -} - -.optionsContainerOpen { - height: auto; - padding-left: 20px; - padding-bottom: 30px; -} - -.optionsContainerClosed { - height: 0; - overflow: hidden; -} - -.columnLayout { -} - -.clearButton { - @apply --marketplaceH5FontStyles; - font-weight: var(--fontWeightMedium); - color: var(--matterColorAnti); - - /* Layout */ - display: inline; - float: right; - margin-top: 6px; - padding: 0; - - /* Override button styles */ - outline: none; - text-align: left; - border: none; - - &:focus, - &:hover { - color: var(--matterColor); - } -} diff --git a/src/components/SelectMultipleFilterPlain/SelectMultipleFilterPlain.example.js b/src/components/SelectMultipleFilterPlain/SelectMultipleFilterPlain.example.js deleted file mode 100644 index 1c38bd08..00000000 --- a/src/components/SelectMultipleFilterPlain/SelectMultipleFilterPlain.example.js +++ /dev/null @@ -1,73 +0,0 @@ -import React from 'react'; -import { withRouter } from 'react-router-dom'; -import SelectMultipleFilterPlain from './SelectMultipleFilterPlain'; -import { stringify, parse } from '../../util/urlHelpers'; - -const URL_PARAM = 'pub_amenities'; - -const options = [ - { - key: 'towels', - label: 'Towels', - }, - { - key: 'bathroom', - label: 'Bathroom', - }, - { - key: 'swimming_pool', - label: 'Swimming pool', - }, - { - key: 'own_drinks', - label: 'Own drinks allowed', - }, - { - key: 'jacuzzi', - label: 'Jacuzzi', - }, - { - key: 'audiovisual_entertainment', - label: 'Audiovisual entertainment', - }, - { - key: 'barbeque', - label: 'Barbeque', - }, - { - key: 'own_food_allowed', - label: 'Own food allowed', - }, -]; - -const handleSelect = (urlParam, values, history) => { - console.log(`handle select`, values); - const queryParams = values ? `?${stringify({ [urlParam]: values.join(',') })}` : ''; - history.push(`${window.location.pathname}${queryParams}`); -}; - -const AmenitiesFilterComponent = props => { - const { history, location } = props; - - const params = parse(location.search); - const amenities = params[URL_PARAM]; - const initialValues = !!amenities ? amenities.split(',') : []; - - return ( - handleSelect(urlParam, values, history)} - initialValues={initialValues} - /> - ); -}; - -export const AmenitiesFilter = { - component: withRouter(AmenitiesFilterComponent), - props: {}, - group: 'misc', -}; diff --git a/src/components/SelectMultipleFilterPlain/SelectMultipleFilterPlain.js b/src/components/SelectMultipleFilterPlain/SelectMultipleFilterPlain.js deleted file mode 100644 index fe5e9a75..00000000 --- a/src/components/SelectMultipleFilterPlain/SelectMultipleFilterPlain.js +++ /dev/null @@ -1,119 +0,0 @@ -import React, { Component } from 'react'; -import { array, bool, func, string } from 'prop-types'; -import classNames from 'classnames'; -import { injectIntl, intlShape, FormattedMessage } from 'react-intl'; - -import { SelectMultipleFilterPlainForm } from '../../forms'; - -import css from './SelectMultipleFilterPlain.css'; - -class SelectMultipleFilterPlainComponent extends Component { - constructor(props) { - super(props); - this.state = { isOpen: true }; - - this.handleSelect = this.handleSelect.bind(this); - this.handleClear = this.handleClear.bind(this); - this.toggleIsOpen = this.toggleIsOpen.bind(this); - } - - handleSelect(values) { - const { urlParam, name, onSelect } = this.props; - const paramValues = values[name]; - onSelect(urlParam, paramValues); - } - - handleClear() { - const { urlParam, onSelect } = this.props; - onSelect(urlParam, null); - } - - toggleIsOpen() { - this.setState(prevState => ({ isOpen: !prevState.isOpen })); - } - - render() { - const { - rootClassName, - className, - id, - name, - label, - options, - initialValues, - intl, - twoColumns, - } = this.props; - - const classes = classNames(rootClassName || css.root, className); - - const hasInitialValues = initialValues.length > 0; - const labelClass = hasInitialValues ? css.filterLabelSelected : css.filterLabel; - - const labelText = hasInitialValues - ? intl.formatMessage( - { id: 'SelectMultipleFilterPlainForm.labelSelected' }, - { labelText: label, count: initialValues.length } - ) - : label; - - const optionsContainerClass = classNames({ - [css.optionsContainerOpen]: this.state.isOpen, - [css.optionsContainerClosed]: !this.state.isOpen, - [css.columnLayout]: twoColumns, - }); - - const namedInitialValues = { [name]: initialValues }; - - return ( -
-
- - -
- -
- ); - } -} - -SelectMultipleFilterPlainComponent.defaultProps = { - rootClassName: null, - className: null, - initialValues: [], - twoColumns: false, -}; - -SelectMultipleFilterPlainComponent.propTypes = { - rootClassName: string, - className: string, - id: string.isRequired, - name: string.isRequired, - urlParam: string.isRequired, - label: string.isRequired, - onSelect: func.isRequired, - options: array.isRequired, - initialValues: array, - twoColumns: bool, - - // from injectIntl - intl: intlShape.isRequired, -}; - -const SelectMultipleFilterPlain = injectIntl(SelectMultipleFilterPlainComponent); - -export default SelectMultipleFilterPlain; diff --git a/src/components/index.js b/src/components/index.js index 9f9bfb05..253bf418 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -102,7 +102,6 @@ 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'; diff --git a/src/examples.js b/src/examples.js index 8a81b56e..aacf23d8 100644 --- a/src/examples.js +++ b/src/examples.js @@ -59,7 +59,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'; @@ -169,7 +168,6 @@ export { Reviews, SectionThumbnailLinks, SelectMultipleFilter, - SelectMultipleFilterPlain, SendMessageForm, SignupForm, StripeBankAccountTokenInputField, diff --git a/src/forms/SelectMultipleFilterForm/SelectMultipleFilterForm.css b/src/forms/SelectMultipleFilterForm/SelectMultipleFilterForm.css deleted file mode 100644 index 563539f7..00000000 --- a/src/forms/SelectMultipleFilterForm/SelectMultipleFilterForm.css +++ /dev/null @@ -1,107 +0,0 @@ -@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; -} - -.buttonsWrapper { - display: flex; -} - -.clearButton { - @apply --marketplaceH4FontStyles; - font-weight: var(--fontWeightMedium); - color: var(--matterColorAnti); - - /* Layout */ - margin: 0 auto 0 0; - padding: 0; - - /* Override button styles */ - outline: none; - border: none; - cursor: pointer; - - &:focus, - &:hover { - color: var(--matterColor); - transition: width var(--transitionStyleButton); - } -} - -.cancelButton { - @apply --marketplaceH4FontStyles; - font-weight: var(--fontWeightMedium); - color: var(--matterColorAnti); - - /* Layout */ - margin: 0; - padding: 0; - - /* Override button styles */ - outline: none; - border: none; - cursor: pointer; - - /* clearButton will add all available space between cancelButton, - * but some hard coded margin is still needed - */ - margin-left: 48px; - - &:focus, - &:hover { - color: var(--matterColor); - transition: width var(--transitionStyleButton); - } -} - -.submitButton { - @apply --marketplaceH4FontStyles; - font-weight: var(--fontWeightMedium); - color: var(--marketplaceColor); - - /* Layout */ - margin: 0 0 0 19px; - padding: 0; - - /* Override button styles */ - outline: none; - border: none; - cursor: pointer; - - &:focus, - &:hover { - color: var(--marketplaceColorDark); - transition: width var(--transitionStyleButton); - } -} diff --git a/src/forms/SelectMultipleFilterForm/SelectMultipleFilterForm.js b/src/forms/SelectMultipleFilterForm/SelectMultipleFilterForm.js deleted file mode 100644 index 88f87222..00000000 --- a/src/forms/SelectMultipleFilterForm/SelectMultipleFilterForm.js +++ /dev/null @@ -1,99 +0,0 @@ -import React from 'react'; -import { arrayOf, bool, func, node, object, shape, string } from 'prop-types'; -import classNames from 'classnames'; -import { Form as FinalForm } from 'react-final-form'; -import { injectIntl, intlShape } from 'react-intl'; -import arrayMutators from 'final-form-arrays'; - -import { FieldCheckboxGroup, Form } from '../../components'; -import css from './SelectMultipleFilterForm.css'; - -const SelectMultipleFilterFormComponent = props => { - return ( - { - const { - form, - handleSubmit, - id, - name, - onClear, - onCancel, - options, - isOpen, - contentRef, - style, - intl, - } = formRenderProps; - const classes = classNames(css.root, { [css.isOpen]: isOpen }); - - const handleCancel = () => { - // reset the final form to initialValues - form.reset(); - onCancel(); - }; - - const clear = intl.formatMessage({ id: 'SelectMultipleFilterForm.clear' }); - const cancel = intl.formatMessage({ id: 'SelectMultipleFilterForm.cancel' }); - const submit = intl.formatMessage({ id: 'SelectMultipleFilterForm.submit' }); - return ( -
- -
- - - -
- - ); - }} - /> - ); -}; - -SelectMultipleFilterFormComponent.defaultProps = { - contentRef: null, - style: null, -}; - -SelectMultipleFilterFormComponent.propTypes = { - id: string.isRequired, - name: string.isRequired, - onClear: func.isRequired, - onCancel: func.isRequired, - options: arrayOf( - shape({ - key: string.isRequired, - label: node.isRequired, - }) - ).isRequired, - isOpen: bool.isRequired, - contentRef: func, - style: object, - - // form injectIntl - intl: intlShape.isRequired, -}; - -const SelectMultipleFilterForm = injectIntl(SelectMultipleFilterFormComponent); - -export default SelectMultipleFilterForm; diff --git a/src/forms/SelectMultipleFilterPlainForm/SelectMultipleFilterPlainForm.js b/src/forms/SelectMultipleFilterPlainForm/SelectMultipleFilterPlainForm.js deleted file mode 100644 index 87e484da..00000000 --- a/src/forms/SelectMultipleFilterPlainForm/SelectMultipleFilterPlainForm.js +++ /dev/null @@ -1,55 +0,0 @@ -import React from 'react'; -import { arrayOf, bool, node, shape, string, func } from 'prop-types'; -import { Form as FinalForm, FormSpy } from 'react-final-form'; -import arrayMutators from 'final-form-arrays'; - -import { FieldCheckboxGroup, Form } from '../../components'; - -const SelectMultipleFilterPlainForm = props => { - const { onChange, ...rest } = props; - - const handleChange = formState => { - if (formState.dirty) { - onChange(formState.values); - } - }; - - return ( - null} - mutators={{ ...arrayMutators }} - render={formRenderProps => { - const { className, id, name, options, twoColumns } = formRenderProps; - return ( -
- - - - ); - }} - /> - ); -}; - -SelectMultipleFilterPlainForm.defaultProps = { - className: null, - twoColumns: false, - onChange: () => null, -}; - -SelectMultipleFilterPlainForm.propTypes = { - className: string, - id: string.isRequired, - name: string.isRequired, - options: arrayOf( - shape({ - key: string.isRequired, - label: node.isRequired, - }) - ).isRequired, - twoColumns: bool, - onChange: func, -}; - -export default SelectMultipleFilterPlainForm; diff --git a/src/forms/index.js b/src/forms/index.js index 198f53de..f3607e97 100644 --- a/src/forms/index.js +++ b/src/forms/index.js @@ -20,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'; From ac3db9efa3d512557f58dcd9afd936e2bf3e0bbd Mon Sep 17 00:00:00 2001 From: Jenni Nurmi Date: Wed, 21 Nov 2018 14:51:52 +0200 Subject: [PATCH 12/20] Refactor SelectSingleFilter to use subcomponents SelectSingleFilterPlain and SelectSingleFilterPopup --- src/components/SearchFilters/SearchFilters.js | 1 + .../SearchFiltersMobile.js | 5 +- .../SelectSingleFilter/SelectSingleFilter.js | 114 ++----------- .../SelectSingleFilterPlain.css | 151 ++++++++++++++++++ .../SelectSingleFilterPlain.js | 121 ++++++++++++++ .../SelectSingleFilterPopup.css | 127 +++++++++++++++ .../SelectSingleFilterPopup.js | 111 +++++++++++++ src/translations/en.json | 4 +- 8 files changed, 529 insertions(+), 105 deletions(-) create mode 100644 src/components/SelectSingleFilter/SelectSingleFilterPlain.css create mode 100644 src/components/SelectSingleFilter/SelectSingleFilterPlain.js create mode 100644 src/components/SelectSingleFilter/SelectSingleFilterPopup.css create mode 100644 src/components/SelectSingleFilter/SelectSingleFilterPopup.js diff --git a/src/components/SearchFilters/SearchFilters.js b/src/components/SearchFilters/SearchFilters.js index f47b89df..a7315962 100644 --- a/src/components/SearchFilters/SearchFilters.js +++ b/src/components/SearchFilters/SearchFilters.js @@ -152,6 +152,7 @@ const SearchFiltersComponent = props => { urlParam={categoryFilter.paramName} label={categoryLabel} onSelect={handleSelectOption} + showAsPopup options={categoryFilter.options} initialValue={initialCategory} contentPlacementOffset={FILTER_DROPDOWN_OFFSET} diff --git a/src/components/SearchFiltersMobile/SearchFiltersMobile.js b/src/components/SearchFiltersMobile/SearchFiltersMobile.js index f5948d51..383c6419 100644 --- a/src/components/SearchFiltersMobile/SearchFiltersMobile.js +++ b/src/components/SearchFiltersMobile/SearchFiltersMobile.js @@ -13,7 +13,7 @@ import { ModalInMobile, Button, PriceFilter, - SelectSingleFilterPlain, + SelectSingleFilter, SelectMultipleFilter, BookingDateRangeFilter, } from '../../components'; @@ -221,10 +221,11 @@ class SearchFiltersMobileComponent extends Component { const initialCategory = categoryFilter ? this.initialValue(categoryFilter.paramName) : null; const categoryFilterElement = categoryFilter ? ( - { - const option = options.find(o => o.key === key); - return option ? option.label : key; +const SelectSingleFilter = props => { + const { showAsPopup, ...rest } = props; + return showAsPopup ? ( + + ) : ( + + ); }; -class SelectSingleFilter extends Component { - constructor(props) { - super(props); - - this.state = { isOpen: false }; - this.onToggleActive = this.onToggleActive.bind(this); - this.selectOption = this.selectOption.bind(this); - } - - onToggleActive(isOpen) { - this.setState({ isOpen: isOpen }); - } - - selectOption(urlParam, option) { - this.setState({ isOpen: false }); - this.props.onSelect(urlParam, option); - } - - render() { - const { - rootClassName, - className, - urlParam, - label, - options, - initialValue, - contentPlacementOffset, - } = this.props; - - // resolve menu label text and class - const menuLabel = initialValue ? optionLabel(options, initialValue) : label; - const menuLabelClass = initialValue ? css.menuLabelSelected : css.menuLabel; - - const classes = classNames(rootClassName || css.root, className); - - return ( - - {menuLabel} - - {options.map(option => { - // check if this option is selected - const selected = initialValue === option.key; - // menu item border class - const menuItemBorderClass = selected ? css.menuItemBorderSelected : css.menuItemBorder; - - return ( - - - - ); - })} - - - - - - ); - } -} - SelectSingleFilter.defaultProps = { - rootClassName: null, - className: null, - initialValue: null, - contentPlacementOffset: 0, + showAsPopup: false, }; SelectSingleFilter.propTypes = { - rootClassName: string, - className: string, - urlParam: string.isRequired, - label: string.isRequired, - onSelect: func.isRequired, - options: arrayOf( - shape({ - key: string.isRequired, - label: string.isRequired, - }) - ).isRequired, - initialValue: string, - contentPlacementOffset: number, + showAsPopup: bool, }; export default SelectSingleFilter; diff --git a/src/components/SelectSingleFilter/SelectSingleFilterPlain.css b/src/components/SelectSingleFilter/SelectSingleFilterPlain.css new file mode 100644 index 00000000..8460c5df --- /dev/null +++ b/src/components/SelectSingleFilter/SelectSingleFilterPlain.css @@ -0,0 +1,151 @@ +@import '../../marketplace.css'; + +.root { + padding-top: 24px; + padding-bottom: 17px; + border-bottom: 1px solid var(--matterColorNegative); +} + +.filterLabel, +.filterLabelSelected { + @apply --marketplaceH3FontStyles; + + /* Baseline adjustment for label text */ + margin-top: 0; + margin-bottom: 12px; + padding: 4px 0 2px 0; +} + +.filterLabel { + color: var(--matterColorDark); +} + +.filterLabelSelected { + color: var(--marketplaceColor); +} + +.labelButton { + /* Override button styles */ + outline: none; + text-align: left; + border: none; + padding: 0; + cursor: pointer; +} + +.optionsContainerOpen { + height: auto; + padding-bottom: 30px; +} + +.optionsContainerClosed { + height: 0; + overflow: hidden; +} + +.hasBullets { + padding-left: 26px; +} + +.twoColumns { + @media (--viewportMedium) { + column-count: 2; + } +} + +.optionBorder, +.optionBorderSelected { + position: absolute; + height: calc(100% - 12px); + top: 4px; + left: -24px; + transition: width var(--transitionStyleButton); +} + +/* left animated "border" like hover element */ +.optionBorder { + width: 0; + background-color: var(--matterColorDark); +} + +/* left static border for selected element */ +.optionBorderSelected { + width: 8px; + background-color: var(--matterColorDark); +} + +.optionBullet, +.optionBulletSelected { + position: absolute; + left: -5px; + top: 13px; + width: 8px; + height: 8px; + border-radius: 4px; + background-color: var(--marketplaceColor); + transition: opacity var(--transitionStyleButton); +} + +/* left animated "border" like hover element */ +.optionBullet { + opacity: 0; +} + +/* left static border for selected element */ +.optionBulletSelected { + opacity: 1; +} + +.option { + @apply --marketplaceH4FontStyles; + font-weight: var(--fontWeightMedium); + font-size: 18px; + color: var(--matterColor); + + /* Layout */ + display: block; + position: relative; + margin: 0; + padding: 4px 0 8px 20px; + + /* Override button styles */ + outline: none; + border: none; + cursor: pointer; + + &:focus, + &:hover { + color: var(--matterColorDark); + } + + &:hover .menuItemBorder { + width: 6px; + } +} + +.optionSelected { + composes: option; + color: var(--matterColorDark); +} + +.clearButton { + @apply --marketplaceH5FontStyles; + font-weight: var(--fontWeightMedium); + color: var(--matterColorAnti); + + /* Layout */ + display: inline; + float: right; + margin-top: 6px; + padding: 0; + + /* Override button styles */ + outline: none; + text-align: left; + border: none; + + &:focus, + &:hover { + color: var(--matterColor); + } +} diff --git a/src/components/SelectSingleFilter/SelectSingleFilterPlain.js b/src/components/SelectSingleFilter/SelectSingleFilterPlain.js new file mode 100644 index 00000000..e99cfb07 --- /dev/null +++ b/src/components/SelectSingleFilter/SelectSingleFilterPlain.js @@ -0,0 +1,121 @@ +import React, { Component } from 'react'; +import { arrayOf, bool, func, shape, string } from 'prop-types'; +import classNames from 'classnames'; +import { FormattedMessage } from 'react-intl'; + +import css from './SelectSingleFilterPlain.css'; + +class SelectSingleFilterPlain extends Component { + constructor(props) { + super(props); + this.state = { isOpen: true }; + this.selectOption = this.selectOption.bind(this); + this.toggleIsOpen = this.toggleIsOpen.bind(this); + } + + selectOption(option, e) { + const { urlParam, onSelect } = this.props; + onSelect(urlParam, option); + + // blur event target if event is passed + if (e && e.currentTarget) { + e.currentTarget.blur(); + } + } + + toggleIsOpen() { + this.setState({ isOpen: !this.state.isOpen }); + } + + render() { + const { + rootClassName, + className, + label, + options, + initialValue, + twoColumns, + useBullets, + } = this.props; + + const labelClass = initialValue ? css.filterLabelSelected : css.filterLabel; + + const hasBullets = useBullets || twoColumns; + const optionsContainerClass = classNames({ + [css.optionsContainerOpen]: this.state.isOpen, + [css.optionsContainerClosed]: !this.state.isOpen, + [css.hasBullets]: hasBullets, + [css.twoColumns]: twoColumns, + }); + + const classes = classNames(rootClassName || css.root, className); + + return ( +
+
+ + +
+
+ {options.map(option => { + // check if this option is selected + const selected = initialValue === option.key; + const optionClass = hasBullets && selected ? css.optionSelected : css.option; + // menu item selected bullet or border class + const optionBorderClass = hasBullets + ? classNames({ + [css.optionBulletSelected]: selected, + [css.optionBullet]: !selected, + }) + : classNames({ + [css.optionBorderSelected]: selected, + [css.optionBorder]: !selected, + }); + return ( + + ); + })} +
+
+ ); + } +} + +SelectSingleFilterPlain.defaultProps = { + rootClassName: null, + className: null, + initialValue: null, + twoColumns: false, + useBullets: false, +}; + +SelectSingleFilterPlain.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, + twoColumns: bool, + useBullets: bool, +}; + +export default SelectSingleFilterPlain; diff --git a/src/components/SelectSingleFilter/SelectSingleFilterPopup.css b/src/components/SelectSingleFilter/SelectSingleFilterPopup.css new file mode 100644 index 00000000..06edfcff --- /dev/null +++ b/src/components/SelectSingleFilter/SelectSingleFilterPopup.css @@ -0,0 +1,127 @@ +@import '../../marketplace.css'; + +.root { + display: inline-block; + + &:last-of-type { + padding-right: 0; + } +} + +.menuLabel { + @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); + } +} + +.menuLabelSelected { + @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); + } +} + +.menuContent { + margin-top: 7px; + padding-top: 13px; + min-width: 300px; + border-radius: 4px; +} + +/* left animated "border" like hover element */ +.menuItemBorder { + position: absolute; + top: 2px; + left: 0px; + height: calc(100% - 4px); + width: 0; + background-color: var(--marketplaceColor); + transition: width var(--transitionStyleButton); +} + +/* left static border for selected element */ +.menuItemBorderSelected { + position: absolute; + top: 2px; + left: 0px; + height: calc(100% - 7px); + width: 6px; + background-color: var(--matterColorDark); +} + +.menuItem { + @apply --marketplaceListingAttributeFontStyles; + color: var(--matterColor); + + /* Layout */ + position: relative; + min-width: 300px; + margin: 0; + padding: 4px 30px; + + /* Override button styles */ + outline: none; + text-align: left; + border: none; + + cursor: pointer; + + &:focus, + &:hover { + color: var(--matterColorDark); + } + + &:hover .menuItemBorder { + width: 6px; + } +} + +.clearMenuItem { + @apply --marketplaceH4FontStyles; + font-weight: var(--fontWeightMedium); + color: var(--matterColorAnti); + + /* Layout */ + position: relative; + min-width: 300px; + margin: 0; + padding: 32px 30px 18px 30px; + + /* Override button styles */ + outline: none; + text-align: left; + border: none; + + cursor: pointer; + transition: width var(--transitionStyleButton); + + &:focus, + &:hover { + color: var(--matterColor); + transition: width var(--transitionStyleButton); + } +} diff --git a/src/components/SelectSingleFilter/SelectSingleFilterPopup.js b/src/components/SelectSingleFilter/SelectSingleFilterPopup.js new file mode 100644 index 00000000..bcce2899 --- /dev/null +++ b/src/components/SelectSingleFilter/SelectSingleFilterPopup.js @@ -0,0 +1,111 @@ +import React, { Component } from 'react'; +import { string, func, arrayOf, shape, number } from 'prop-types'; +import { FormattedMessage } from 'react-intl'; +import classNames from 'classnames'; + +import { Menu, MenuContent, MenuItem, MenuLabel } from '..'; +import css from './SelectSingleFilterPopup.css'; + +const optionLabel = (options, key) => { + const option = options.find(o => o.key === key); + return option ? option.label : key; +}; + +class SelectSingleFilterPopup extends Component { + constructor(props) { + super(props); + + this.state = { isOpen: false }; + this.onToggleActive = this.onToggleActive.bind(this); + this.selectOption = this.selectOption.bind(this); + } + + onToggleActive(isOpen) { + this.setState({ isOpen: isOpen }); + } + + selectOption(urlParam, option) { + this.setState({ isOpen: false }); + this.props.onSelect(urlParam, option); + } + + render() { + const { + rootClassName, + className, + urlParam, + label, + options, + initialValue, + contentPlacementOffset, + } = this.props; + + // resolve menu label text and class + const menuLabel = initialValue ? optionLabel(options, initialValue) : label; + const menuLabelClass = initialValue ? css.menuLabelSelected : css.menuLabel; + + const classes = classNames(rootClassName || css.root, className); + + return ( + + {menuLabel} + + {options.map(option => { + // check if this option is selected + const selected = initialValue === option.key; + // menu item border class + const menuItemBorderClass = selected ? css.menuItemBorderSelected : css.menuItemBorder; + + return ( + + + + ); + })} + + + + + + ); + } +} + +SelectSingleFilterPopup.defaultProps = { + rootClassName: null, + className: null, + initialValue: null, + contentPlacementOffset: 0, +}; + +SelectSingleFilterPopup.propTypes = { + rootClassName: string, + className: string, + urlParam: string.isRequired, + label: string.isRequired, + onSelect: func.isRequired, + options: arrayOf( + shape({ + key: string.isRequired, + label: string.isRequired, + }) + ).isRequired, + initialValue: string, + contentPlacementOffset: number, +}; + +export default SelectSingleFilterPopup; diff --git a/src/translations/en.json b/src/translations/en.json index d2d15955..8ffe89d4 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -691,8 +691,8 @@ "SelectMultipleFilter.labelSelected": "{labelText} • {count}", "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", From b8db69b5b791fddbbf7822db0917dab695564aa1 Mon Sep 17 00:00:00 2001 From: Jenni Nurmi Date: Wed, 21 Nov 2018 14:56:45 +0200 Subject: [PATCH 13/20] Delete unused code related to SelectSingleFilter --- .../SelectSingleFilter/SelectSingleFilter.css | 127 --------------- .../SelectSingleFilterPlain.css | 151 ------------------ .../SelectSingleFilterPlain.js | 121 -------------- src/components/index.js | 1 - 4 files changed, 400 deletions(-) delete mode 100644 src/components/SelectSingleFilter/SelectSingleFilter.css delete mode 100644 src/components/SelectSingleFilterPlain/SelectSingleFilterPlain.css delete mode 100644 src/components/SelectSingleFilterPlain/SelectSingleFilterPlain.js diff --git a/src/components/SelectSingleFilter/SelectSingleFilter.css b/src/components/SelectSingleFilter/SelectSingleFilter.css deleted file mode 100644 index 06edfcff..00000000 --- a/src/components/SelectSingleFilter/SelectSingleFilter.css +++ /dev/null @@ -1,127 +0,0 @@ -@import '../../marketplace.css'; - -.root { - display: inline-block; - - &:last-of-type { - padding-right: 0; - } -} - -.menuLabel { - @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); - } -} - -.menuLabelSelected { - @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); - } -} - -.menuContent { - margin-top: 7px; - padding-top: 13px; - min-width: 300px; - border-radius: 4px; -} - -/* left animated "border" like hover element */ -.menuItemBorder { - position: absolute; - top: 2px; - left: 0px; - height: calc(100% - 4px); - width: 0; - background-color: var(--marketplaceColor); - transition: width var(--transitionStyleButton); -} - -/* left static border for selected element */ -.menuItemBorderSelected { - position: absolute; - top: 2px; - left: 0px; - height: calc(100% - 7px); - width: 6px; - background-color: var(--matterColorDark); -} - -.menuItem { - @apply --marketplaceListingAttributeFontStyles; - color: var(--matterColor); - - /* Layout */ - position: relative; - min-width: 300px; - margin: 0; - padding: 4px 30px; - - /* Override button styles */ - outline: none; - text-align: left; - border: none; - - cursor: pointer; - - &:focus, - &:hover { - color: var(--matterColorDark); - } - - &:hover .menuItemBorder { - width: 6px; - } -} - -.clearMenuItem { - @apply --marketplaceH4FontStyles; - font-weight: var(--fontWeightMedium); - color: var(--matterColorAnti); - - /* Layout */ - position: relative; - min-width: 300px; - margin: 0; - padding: 32px 30px 18px 30px; - - /* Override button styles */ - outline: none; - text-align: left; - border: none; - - cursor: pointer; - transition: width var(--transitionStyleButton); - - &:focus, - &:hover { - color: var(--matterColor); - transition: width var(--transitionStyleButton); - } -} diff --git a/src/components/SelectSingleFilterPlain/SelectSingleFilterPlain.css b/src/components/SelectSingleFilterPlain/SelectSingleFilterPlain.css deleted file mode 100644 index 8460c5df..00000000 --- a/src/components/SelectSingleFilterPlain/SelectSingleFilterPlain.css +++ /dev/null @@ -1,151 +0,0 @@ -@import '../../marketplace.css'; - -.root { - padding-top: 24px; - padding-bottom: 17px; - border-bottom: 1px solid var(--matterColorNegative); -} - -.filterLabel, -.filterLabelSelected { - @apply --marketplaceH3FontStyles; - - /* Baseline adjustment for label text */ - margin-top: 0; - margin-bottom: 12px; - padding: 4px 0 2px 0; -} - -.filterLabel { - color: var(--matterColorDark); -} - -.filterLabelSelected { - color: var(--marketplaceColor); -} - -.labelButton { - /* Override button styles */ - outline: none; - text-align: left; - border: none; - padding: 0; - cursor: pointer; -} - -.optionsContainerOpen { - height: auto; - padding-bottom: 30px; -} - -.optionsContainerClosed { - height: 0; - overflow: hidden; -} - -.hasBullets { - padding-left: 26px; -} - -.twoColumns { - @media (--viewportMedium) { - column-count: 2; - } -} - -.optionBorder, -.optionBorderSelected { - position: absolute; - height: calc(100% - 12px); - top: 4px; - left: -24px; - transition: width var(--transitionStyleButton); -} - -/* left animated "border" like hover element */ -.optionBorder { - width: 0; - background-color: var(--matterColorDark); -} - -/* left static border for selected element */ -.optionBorderSelected { - width: 8px; - background-color: var(--matterColorDark); -} - -.optionBullet, -.optionBulletSelected { - position: absolute; - left: -5px; - top: 13px; - width: 8px; - height: 8px; - border-radius: 4px; - background-color: var(--marketplaceColor); - transition: opacity var(--transitionStyleButton); -} - -/* left animated "border" like hover element */ -.optionBullet { - opacity: 0; -} - -/* left static border for selected element */ -.optionBulletSelected { - opacity: 1; -} - -.option { - @apply --marketplaceH4FontStyles; - font-weight: var(--fontWeightMedium); - font-size: 18px; - color: var(--matterColor); - - /* Layout */ - display: block; - position: relative; - margin: 0; - padding: 4px 0 8px 20px; - - /* Override button styles */ - outline: none; - border: none; - cursor: pointer; - - &:focus, - &:hover { - color: var(--matterColorDark); - } - - &:hover .menuItemBorder { - width: 6px; - } -} - -.optionSelected { - composes: option; - color: var(--matterColorDark); -} - -.clearButton { - @apply --marketplaceH5FontStyles; - font-weight: var(--fontWeightMedium); - color: var(--matterColorAnti); - - /* Layout */ - display: inline; - float: right; - margin-top: 6px; - padding: 0; - - /* Override button styles */ - outline: none; - text-align: left; - border: none; - - &:focus, - &:hover { - color: var(--matterColor); - } -} diff --git a/src/components/SelectSingleFilterPlain/SelectSingleFilterPlain.js b/src/components/SelectSingleFilterPlain/SelectSingleFilterPlain.js deleted file mode 100644 index 63a085c9..00000000 --- a/src/components/SelectSingleFilterPlain/SelectSingleFilterPlain.js +++ /dev/null @@ -1,121 +0,0 @@ -import React, { Component } from 'react'; -import { arrayOf, bool, func, shape, string } from 'prop-types'; -import classNames from 'classnames'; -import { FormattedMessage } from 'react-intl'; - -import css from './SelectSingleFilterPlain.css'; - -class SelectSingleFilterPlain extends Component { - constructor(props) { - super(props); - this.state = { isOpen: true }; - this.selectOption = this.selectOption.bind(this); - this.toggleIsOpen = this.toggleIsOpen.bind(this); - } - - selectOption(option, e) { - const { urlParam, onSelect } = this.props; - onSelect(urlParam, option); - - // blur event target if event is passed - if (e && e.currentTarget) { - e.currentTarget.blur(); - } - } - - toggleIsOpen() { - this.setState({ isOpen: !this.state.isOpen }); - } - - render() { - const { - rootClassName, - className, - label, - options, - initialValue, - twoColumns, - useBullets, - } = this.props; - - const labelClass = initialValue ? css.filterLabelSelected : css.filterLabel; - - const hasBullets = useBullets || twoColumns; - const optionsContainerClass = classNames({ - [css.optionsContainerOpen]: this.state.isOpen, - [css.optionsContainerClosed]: !this.state.isOpen, - [css.hasBullets]: hasBullets, - [css.twoColumns]: twoColumns, - }); - - const classes = classNames(rootClassName || css.root, className); - - return ( -
-
- - -
-
- {options.map(option => { - // check if this option is selected - const selected = initialValue === option.key; - const optionClass = hasBullets && selected ? css.optionSelected : css.option; - // menu item selected bullet or border class - const optionBorderClass = hasBullets - ? classNames({ - [css.optionBulletSelected]: selected, - [css.optionBullet]: !selected, - }) - : classNames({ - [css.optionBorderSelected]: selected, - [css.optionBorder]: !selected, - }); - return ( - - ); - })} -
-
- ); - } -} - -SelectSingleFilterPlain.defaultProps = { - rootClassName: null, - className: null, - initialValue: null, - twoColumns: false, - useBullets: false, -}; - -SelectSingleFilterPlain.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, - twoColumns: bool, - useBullets: bool, -}; - -export default SelectSingleFilterPlain; diff --git a/src/components/index.js b/src/components/index.js index 253bf418..cfea1f16 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -103,7 +103,6 @@ export { default as SectionLocations } from './SectionLocations/SectionLocations export { default as SectionThumbnailLinks } from './SectionThumbnailLinks/SectionThumbnailLinks'; export { default as SelectMultipleFilter } from './SelectMultipleFilter/SelectMultipleFilter'; 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'; From e5238ca78bc14c1afc6abbf7359c35f3e6c6845d Mon Sep 17 00:00:00 2001 From: Jenni Nurmi Date: Tue, 27 Nov 2018 12:30:00 +0200 Subject: [PATCH 14/20] Create OutsideClickHandler --- .../OutsideClickHandler.css | 5 ++ .../OutsideClickHandler.example.js | 39 +++++++++++++++ .../OutsideClickHandler.js | 50 +++++++++++++++++++ src/components/index.js | 1 + src/examples.js | 2 + 5 files changed, 97 insertions(+) create mode 100644 src/components/OutsideClickHandler/OutsideClickHandler.css create mode 100644 src/components/OutsideClickHandler/OutsideClickHandler.example.js create mode 100644 src/components/OutsideClickHandler/OutsideClickHandler.js diff --git a/src/components/OutsideClickHandler/OutsideClickHandler.css b/src/components/OutsideClickHandler/OutsideClickHandler.css new file mode 100644 index 00000000..9b0dbeb1 --- /dev/null +++ b/src/components/OutsideClickHandler/OutsideClickHandler.css @@ -0,0 +1,5 @@ +@import '../../marketplace.css'; + +.root { + display: inline-block; +} diff --git a/src/components/OutsideClickHandler/OutsideClickHandler.example.js b/src/components/OutsideClickHandler/OutsideClickHandler.example.js new file mode 100644 index 00000000..aef9581d --- /dev/null +++ b/src/components/OutsideClickHandler/OutsideClickHandler.example.js @@ -0,0 +1,39 @@ +import React, { Component } from 'react'; +import OutsideClickHandler from './OutsideClickHandler'; + +const childStyle = { + padding: '16px', + background: '#e7e7e7', +}; + +class OutsideClickHandlerWrapper extends Component { + constructor(props) { + super(props); + + this.state = { + message: 'This is OutsideClickHandler example', + }; + + this.handleClick = this.handleClick.bind(this); + } + + handleClick() { + this.setState({ message: 'You clicked outside!' }); + } + + render() { + return ( + +
+

{this.state.message}

+
+
+ ); + } +} + +export const FilterPopupExample = { + component: OutsideClickHandlerWrapper, + props: {}, + group: 'misc', +}; diff --git a/src/components/OutsideClickHandler/OutsideClickHandler.js b/src/components/OutsideClickHandler/OutsideClickHandler.js new file mode 100644 index 00000000..e424f434 --- /dev/null +++ b/src/components/OutsideClickHandler/OutsideClickHandler.js @@ -0,0 +1,50 @@ +import React, { Component } from 'react'; +import { func, node, string } from 'prop-types'; +import classNames from 'classnames'; + +import css from './OutsideClickHandler.css'; + +export default class OutsideClickHandler extends Component { + constructor(props) { + super(props); + + this.handleClick = this.handleClick.bind(this); + } + + componentDidMount() { + document.addEventListener('mousedown', this.handleClick, false); + } + + componentWillUnmount() { + document.removeEventListener('mousedown', this.handleClick, false); + } + + handleClick(event) { + if (!this.node.contains(event.target)) { + this.props.onOutsideClick(); + } + } + + render() { + const { rootClassName, className, children } = this.props; + const classes = classNames(rootClassName || css.root, className); + + return ( +
(this.node = node)}> + {children} +
+ ); + } +} + +OutsideClickHandler.defaultProps = { + rootClassName: null, + className: null, +}; + +OutsideClickHandler.propTypes = { + rootClassName: string, + className: string, + onOutsideClick: func.isRequired, + children: node.isRequired, +}; diff --git a/src/components/index.js b/src/components/index.js index cfea1f16..2f82f024 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -78,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'; diff --git a/src/examples.js b/src/examples.js index aacf23d8..25f4bb58 100644 --- a/src/examples.js +++ b/src/examples.js @@ -50,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'; @@ -155,6 +156,7 @@ export { Modal, ModalInMobile, NamedLink, + OutsideClickHandler, PaginationLinks, PasswordRecoveryForm, PasswordResetForm, From 56151850dd0b43be9f39d54f5ddc9abadfe298c0 Mon Sep 17 00:00:00 2001 From: Jenni Nurmi Date: Wed, 28 Nov 2018 16:00:12 +0200 Subject: [PATCH 15/20] Use OutsideClickHandler in FilterPopup --- src/components/FilterPopup/FilterPopup.js | 70 +++++++++++------------ 1 file changed, 34 insertions(+), 36 deletions(-) diff --git a/src/components/FilterPopup/FilterPopup.js b/src/components/FilterPopup/FilterPopup.js index 3ba087c0..dd076349 100644 --- a/src/components/FilterPopup/FilterPopup.js +++ b/src/components/FilterPopup/FilterPopup.js @@ -3,6 +3,7 @@ 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'; @@ -53,12 +54,8 @@ class FilterPopup extends Component { onSubmit(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 (event.relatedTarget && !this.filter.contains(event.relatedTarget)) { - this.setState({ isOpen: false }); - } + handleBlur() { + this.setState({ isOpen: false }); } handleKeyDown(e) { @@ -119,41 +116,42 @@ class FilterPopup extends Component { const contentStyle = this.positionStyleForContent(); return ( -
{ - this.filter = node; - }} - > - +
{ - this.filterContent = node; + this.filter = node; }} - style={contentStyle} > - {this.state.isOpen ? ( - - {children} - - ) : null} + +
{ + this.filterContent = node; + }} + style={contentStyle} + > + {this.state.isOpen ? ( + + {children} + + ) : null} +
-
+ ); } } From 2177ac39c9ac0c4930af0176498b60b33fec558c Mon Sep 17 00:00:00 2001 From: Jenni Nurmi Date: Mon, 10 Dec 2018 14:28:45 +0200 Subject: [PATCH 16/20] Update search filters documentation --- docs/search-filters.md | 59 ++++++++++++++++++++++++++---------------- 1 file changed, 37 insertions(+), 22 deletions(-) diff --git a/docs/search-filters.md b/docs/search-filters.md index b8057d2b..89582094 100644 --- a/docs/search-filters.md +++ b/docs/search-filters.md @@ -3,28 +3,31 @@ The search experience can be improved by adding search filters to narrow down the results. The filters rely on listing's indexed data. +- [Filter types](#filter-types) +- [Adding a new search filter](#adding-a-new-search-filter) + - [Common changes](#common-changes) + - [Desktop filters](#desktop-filters) + - [Desktop filters panel](#desktop-filters-panel) + - [Mobile filters](#mobile-filters) +- [Creating your own filter types](#creating-your-own-filter-types) + ## Filter types -The Flex template for web has three different filter types: _price filter_, _select single_ and -_select multiple_. The _price filter_ is for the specific case of filtering listings with a price -range. +The Flex template for web has different filter types: _price_, _date range_, _select single_ and +_select multiple_. Select single and select multiple filters are generic in a way that they can be +used to filter search results using different kinds of data. The price and date range filters on the +other hand are only used for filtering by price and date range. > NOTE: price filter should be configured from `src/marketplace-custom-config.js`. Current maximum > value for the range is set to 1000 (USD/EUR). -Other two filter types can be used with extended data. The _select single_ one can be used to filter -out search result with only one value per search parameter. The _select multiple_ filters on the -other hand can take multiple values for a single search parameter. These two filter types for -extended data are implemented with four different components, a standard and a plain one: +Filters _select single_ and _select multiple_ can be used with extended data. The _select single_ +one can be used to filter out search results with only one value per search parameter. The _select +multiple_ filters on the other hand can take multiple values for a single search parameter. These +two filter types for extended data are implemented with two different components: -- Select single filter: `SelectSingleFilter` and `SelectSingleFilterPlain` -- Select multiple filter: `SelectMultipleFilter` and `SelectMultipleFilterPlain` - -The `SelectSingleFilter` and `SelectMultipleFilter` components are rendered as standard dropdowns in -the search view. The plain filter components `SelectSingleFilterPlain` and -`SelectMultipleFilterPlain` are used with `SearchFiltersMobile` and `SearchFiltersPanel` to provider -filters in mobile view or to open filters in a distinct panel in order to fit more filters to the -desktop search view. +- Select single filter: `SelectSingleFilter` +- Select multiple filter: `SelectMultipleFilter` ## Adding a new search filter @@ -147,7 +150,7 @@ desktop filters, perform the following in `SearchFilters` component: - declare a prop with the same name that you added the filter config to `primaryFilters` - resolve the filters initial value with `initialValue` and `initialValues` functions - render the filter by using a `SelectSingleFilter` or `SelectMultipleFilter` component inside the - `
` element + `
` element. ### Desktop filters panel @@ -161,19 +164,31 @@ To use the `SearchFiltersPanel`, do the following: - declare a prop with the same name that you added the filter config to `secondaryFilters` - resolve the filters initial value with `initialValue` and `initialValues` methods -- use the `SelectSingleFilterPlain` and `SelectMultipleFilterPlain` components inside the - `
` element to render the filters +- use the `SelectSingleFilter` and `SelectMultipleFilter` components inside the + `
` element to render the filters. ### Mobile filters ![Mobile filters](./assets/search-filters/mobile-filters.png) -The mobile view uses the same `SelectSingleFilterPlain` and `SelectMultipleFilterPlain` components -as the filter panel. In this case the filter components are declared in `SearchFiltersMobile`. The +The mobile view uses the same `SelectSingleFilter` and `SelectMultipleFilter` components as the +filter panel. In this case the filter components are declared in `SearchFiltersMobile`. The following steps are required to add a mobile filter: - declare a prop with the same name that you added the filter config to `primaryFilters` or `secondaryFilters` - resolve the filters initial value with `initialValue` and `initialValues` methods -- use the `SelectSingleFilterPlain` and `SelectMultipleFilterPlain` components inside the - `
` element to render the filters +- use the `SelectSingleFilter` and `SelectMultipleFilter` components inside the + `
` element to render the filters. + +## Creating your own filter types + +If you are creating new filter types note that we are using two different types of components: popup +and plain. Popup components are rendered as primary dropdowns in the search view in `SearchFilters`. +Plain components are used with `SearchFiltersMobile` and `SearchFiltersPanel`. `SearchFiltersPanel` +opens sacondary filters in a distinct panel in order to fit additional filters to the desktop search +view. + +To make creating new filters easier, there are two generic components: `FilterPoup` and +`FilterPlain`. These components expect that you give form fields as child component. Check +`SelectMultipleFilter` to see how these components work. From 2dcea906ecbe51e11b3a14bf2ec061246892003d Mon Sep 17 00:00:00 2001 From: Vesa Luusua Date: Tue, 29 Jan 2019 12:58:55 +0200 Subject: [PATCH 17/20] Capitalize month name --- .../FieldDateRangeController/DateRangeController.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/components/FieldDateRangeController/DateRangeController.css b/src/components/FieldDateRangeController/DateRangeController.css index 15193543..6c55b669 100644 --- a/src/components/FieldDateRangeController/DateRangeController.css +++ b/src/components/FieldDateRangeController/DateRangeController.css @@ -92,6 +92,10 @@ padding-top: 31px; padding-bottom: 37px; + &::first-letter { + text-transform: capitalize; + } + @media (--viewportMedium) { margin-top: 0; margin-bottom: 0; From c1850724823985bdc0ca8810edb0d56bad5ba816 Mon Sep 17 00:00:00 2001 From: Jenni Nurmi Date: Mon, 10 Dec 2018 14:29:00 +0200 Subject: [PATCH 18/20] Update translations --- src/translations/en.json | 1 - src/translations/es.json | 20 +++++++++++++------- src/translations/fr.json | 22 ++++++++++++++-------- 3 files changed, 27 insertions(+), 16 deletions(-) diff --git a/src/translations/en.json b/src/translations/en.json index 8ffe89d4..54762a51 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -689,7 +689,6 @@ "SectionLocations.listingsInLocation": "Saunas in {location}", "SectionLocations.title": "Explore exotic locations in Finland", "SelectMultipleFilter.labelSelected": "{labelText} • {count}", - "SelectMultipleFilterPlainForm.clear": "Clear", "SelectMultipleFilterPlainForm.labelSelected": "{labelText} • {count}", "SelectSingleFilter.popupClear": "Clear", "SelectSingleFilter.plainClear": "Clear", diff --git a/src/translations/es.json b/src/translations/es.json index cbc7c841..fe775c17 100644 --- a/src/translations/es.json +++ b/src/translations/es.json @@ -67,6 +67,10 @@ "BookingDatesForm.requiredDate": "Ups, asegúrate de que la fecha es correcta!", "BookingDatesForm.timeSlotsError": "Error al cargar la disponibilidad del anuncio. Por favor, actualiza la página.", "BookingDatesForm.youWontBeChargedInfo": "Aún no se hará el cargo a tu tarjeta", + "BookingDateRangeFilter.labelPlain": "Fechas", + "BookingDateRangeFilter.labelPopup": "Fechas", + "BookingDateRangeFilter.labelSelectedPlain": "{dates}", + "BookingDateRangeFilter.labelSelectedPopup": "{dates}", "BookingPanel.closedListingButtonText": "Lo sentimos, este anuncio ha sido cerrado.", "BookingPanel.ctaButtonMessage": "Solicitar reserva", "BookingPanel.hostedBy": "Publicado por {name}", @@ -256,6 +260,10 @@ "FieldReviewRating.star3": "Bien - 3 estrellas", "FieldReviewRating.star4": "Muy bien - 4 estrellas", "FieldReviewRating.star5": "Excelente - 5 estrellas", + "FilterForm.cancel": "Cancelar", + "FilterForm.clear": "Borrar", + "FilterForm.submit": "Aplicar", + "FilterPlain.clear": "Borrar", "Footer.copyright": "© Sharetribe", "Footer.goToFacebook": "Ir a Facebook", "Footer.goToInstagram": "Ir a Instagram", @@ -453,6 +461,8 @@ "PasswordResetPage.resetFailed": "Ha ocurrido un error. Por favor, inténtalo de nuevo.", "PasswordResetPage.title": "Restablecer contraseña", "PayoutDetailsForm.accountTypeTitle": "Tipo de cuenta", + "PayoutDetailsForm.additionalOwnersInfoLink": "páginas de soporte de Stripe.", + "PayoutDetailsForm.additionalOwnerInfoText": "Para Hong Kong, Singapur, y países miembros de la Zona Única de Pagos en Euros, Stripe requiere información de cada persona que sea dueña de por lo menos 25% de la empresa. Para mas información, visita las {additionalOwnersInfoLink}", "PayoutDetailsForm.additionalOwnerLabel": "Añadir proprietario adicional", "PayoutDetailsForm.additionalOwnerRemove": "Eliminar propietario adicional", "PayoutDetailsForm.addressTitle": "Dirección", @@ -630,7 +640,7 @@ "ReviewForm.reviewContentPlaceholder": "Describe tu experiencia...", "ReviewForm.reviewContentRequired": "Necesitas añadir un comentario a tu valoración", "ReviewForm.reviewRatingLabel": "Valora tu experiencia", - "ReviewForm.reviewRatingRequired": "Necesitas añadir un comentario a tu valoración", + "ReviewForm.reviewRatingRequired": "Necesitas añadir un comentario a tu valoración.", "ReviewForm.reviewSubmit": "Publicar valoración", "ReviewForm.reviewSubmitAlreadySent": "La valoración ya ha sido enviada. Por favor, actualiza la página.", "ReviewForm.reviewSubmitFailed": "Error al enviar la valoración. Por favor, inténtalo de nuevo.", @@ -679,13 +689,9 @@ "SectionLocations.listingsInLocation": "Saunas en {location}", "SectionLocations.title": "Explora lugares exóticos en Finlandia", "SelectMultipleFilter.labelSelected": "{labelText} • {count}", - "SelectMultipleFilterForm.cancel": "Cancelar", - "SelectMultipleFilterForm.clear": "Borrar", - "SelectMultipleFilterForm.submit": "Aplicar", - "SelectMultipleFilterPlainForm.clear": "Borrar", "SelectMultipleFilterPlainForm.labelSelected": "{labelText} • {count}", - "SelectSingleFilter.clear": "Borrar", - "SelectSingleFilterPlain.clear": "Borrar", + "SelectSingleFilter.popupClear": "Borrar", + "SelectSingleFilter.plainClear": "Borrar", "SendMessageForm.sendFailed": "Error al enviar. Por favor, inténtalo de nuevo.", "SendMessageForm.sendMessage": "Enviar mensaje", "SignupForm.emailInvalid": "Se necesita una dirección de correo electrónico válida", diff --git a/src/translations/fr.json b/src/translations/fr.json index 302e5ae0..220016b5 100644 --- a/src/translations/fr.json +++ b/src/translations/fr.json @@ -67,6 +67,10 @@ "BookingDatesForm.requiredDate": "Oups, vérifiez que la date est correcte", "BookingDatesForm.timeSlotsError": "Le chargement des disponibilités pour cette annonce a échoué. Veuillez rafraîchir la page.", "BookingDatesForm.youWontBeChargedInfo": "Vous ne serez pas facturé immédiatement", + "BookingDateRangeFilter.labelPlain": "Dates", + "BookingDateRangeFilter.labelPopup": "Dates", + "BookingDateRangeFilter.labelSelectedPlain": "{dates}", + "BookingDateRangeFilter.labelSelectedPopup": "{dates}", "BookingPanel.closedListingButtonText": "Navré, cette annonce est close.", "BookingPanel.ctaButtonMessage": "Réserver", "BookingPanel.hostedBy": "Proposé par {name}", @@ -256,6 +260,10 @@ "FieldReviewRating.star3": "Moyen - 3 étoiles", "FieldReviewRating.star4": "Correct - 4 étoiles", "FieldReviewRating.star5": "Génial - 5 étoiles", + "FilterForm.cancel": "Annuler", + "FilterForm.clear": "Effacer", + "FilterForm.submit": "Appliquer", + "FilterPlain.clear": "Effacer", "Footer.copyright": "© Sharetribe", "Footer.goToFacebook": "Aller sur la page Facebook", "Footer.goToInstagram": "Aller sur la page Instagram", @@ -453,6 +461,8 @@ "PasswordResetPage.resetFailed": "La réinitialisation a échoué.", "PasswordResetPage.title": "Réinitialiser le mot de passe", "PayoutDetailsForm.accountTypeTitle": "Type de compte", + "PayoutDetailsForm.additionalOwnersInfoLink": "le support Stripe", + "PayoutDetailsForm.additionalOwnerInfoText": "Pour Hong Kong, Singapore and les pays membres de la zone Euro, Stripe requiert les informations de chaque personne possédant au moins 24% de l'entreprise. Pour plus d'information, parcourez {additionalOwnersInfoLink}.", "PayoutDetailsForm.additionalOwnerLabel": "Ajouter un autre propriétaire", "PayoutDetailsForm.additionalOwnerRemove": "Enlever un propriétaire", "PayoutDetailsForm.addressTitle": "Adresse", @@ -488,7 +498,7 @@ "PayoutDetailsForm.companyAddressTitle": "Adresse de l'entreprise", "PayoutDetailsForm.companyDetailsTitle": "Détails de l'entreprise", "PayoutDetailsForm.companyNameLabel": "Nom de l'entreprise", - "PayoutDetailsForm.companyNamePlaceholder": "Entrez le nom de l'entreprise...", + "PayoutDetailsForm.companyNamePlaceholder": "Entrez le nom de l'entreprise…", "PayoutDetailsForm.companyNameRequired": "Le nom de l'entreprise est requis", "PayoutDetailsForm.companyTaxIdLabel.AT": "Firmenbuchnummer (FN)", "PayoutDetailsForm.companyTaxIdLabel.AU": "ACN/ABN - TFN de l'entreprise", @@ -511,7 +521,7 @@ "PayoutDetailsForm.companyTaxIdLabel.PT": "Numéro Contribuinte", "PayoutDetailsForm.companyTaxIdLabel.SE": "Numéro d'organisation", "PayoutDetailsForm.companyTaxIdLabel.US": "Identifiant taxes (Tax ID)", - "PayoutDetailsForm.companyTaxIdPlaceholder": "Entrez {idName}", + "PayoutDetailsForm.companyTaxIdPlaceholder": "Entrez {idName}…", "PayoutDetailsForm.companyTaxIdRequired": "{idName} est requis", "PayoutDetailsForm.countryLabel": "Pays", "PayoutDetailsForm.countryNames.AT": "Autriche", @@ -679,13 +689,9 @@ "SectionLocations.listingsInLocation": "Saunas à {location}", "SectionLocations.title": "Réchauffez-vous depuis une destination exotique en Finlande", "SelectMultipleFilter.labelSelected": "{labelText} • {count}", - "SelectMultipleFilterForm.cancel": "Annuler", - "SelectMultipleFilterForm.clear": "Effacer", - "SelectMultipleFilterForm.submit": "Valider", - "SelectMultipleFilterPlainForm.clear": "Effacer", "SelectMultipleFilterPlainForm.labelSelected": "{labelText} • {count}", - "SelectSingleFilter.clear": "Effacer", - "SelectSingleFilterPlain.clear": "Effacer", + "SelectSingleFilter.popupClear": "Effacer", + "SelectSingleFilter.plainClear": "Effacer", "SendMessageForm.sendFailed": "Impossible d'envoyer. Veuillez essayer de nouveau.", "SendMessageForm.sendMessage": "Envoyer le message", "SignupForm.emailInvalid": "Une adresse email valide est requise.", From 94bb6be20adabc10aaa567b9b77f9fdf7d7ec44c Mon Sep 17 00:00:00 2001 From: Jenni Nurmi Date: Thu, 3 Jan 2019 14:20:36 +0200 Subject: [PATCH 19/20] Add availability params to search query --- src/containers/SearchPage/SearchPage.duck.js | 22 +++++++++++++- src/util/dates.js | 31 ++++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/src/containers/SearchPage/SearchPage.duck.js b/src/containers/SearchPage/SearchPage.duck.js index be796592..27a4a626 100644 --- a/src/containers/SearchPage/SearchPage.duck.js +++ b/src/containers/SearchPage/SearchPage.duck.js @@ -2,6 +2,7 @@ import unionWith from 'lodash/unionWith'; import { storableError } from '../../util/errors'; import { addMarketplaceEntities } from '../../ducks/marketplaceData.duck'; import { convertUnitToSubUnit, unitDivisor } from '../../util/currency'; +import { formatDateStringToUTC, getExclusiveEndDate } from '../../util/dates'; import config from '../../config'; // ================ Action types ================ // @@ -131,11 +132,30 @@ export const searchListings = searchParams => (dispatch, getState, sdk) => { : {}; }; - const { perPage, price, ...rest } = searchParams; + const datesSearchParams = datesParam => { + const values = datesParam ? datesParam.split(',') : []; + const hasValues = datesParam && values.length === 2; + const startDate = hasValues ? values[0] : null; + const endDate = hasValues ? getExclusiveEndDate(values[1]) : null; + + return hasValues + ? { + start: formatDateStringToUTC(startDate), + end: formatDateStringToUTC(endDate), + // Availability can be full or partial. Default value is full. + availability: 'full', + } + : {}; + }; + + const { perPage, price, dates, ...rest } = searchParams; const priceMaybe = priceSearchParams(price); + const datesMaybe = datesSearchParams(dates); + const params = { ...rest, ...priceMaybe, + ...datesMaybe, per_page: perPage, }; diff --git a/src/util/dates.js b/src/util/dates.js index 13bee7f1..326ae73e 100644 --- a/src/util/dates.js +++ b/src/util/dates.js @@ -194,3 +194,34 @@ export const parseDateFromISO8601 = dateString => { 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(); +}; From 133ee1d44d3f6c84049ab16659947c52c27f91ef Mon Sep 17 00:00:00 2001 From: Vesa Luusua Date: Tue, 29 Jan 2019 13:20:30 +0200 Subject: [PATCH 20/20] Update changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a84e555..90731603 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ way to update this template, but currently, we follow a pattern: ## Upcoming version 2019-XX-XX +- [add] Date filter added and filter components (single and multiselect) are refactored to use + shared subcomponents. [#949](https://github.com/sharetribe/flex-template-web/pull/949) - [fix] Fixed copy-text in ReviewForm: Rating is required. [#1011](https://github.com/sharetribe/flex-template-web/pull/1011) - [change] Some of the documentation moved to Flex Docs: https://www.sharetribe.com/docs/