Merge pull request #714 from sharetribe/searchfilterspanel

Searchfilterspanel
This commit is contained in:
Vesa Luusua 2018-02-23 17:35:09 +02:00 committed by GitHub
commit cf6ed72a0d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 500 additions and 39 deletions

View file

@ -47,6 +47,7 @@
display: inline-flex;
align-items: center;
cursor: pointer;
}
.checked {
@ -63,4 +64,5 @@
color: var(--matterColor);
margin-top: -1px;
margin-bottom: 1px;
cursor: pointer;
}

View file

@ -38,7 +38,7 @@
margin-top: 16px;
margin-bottom: 12px;
/* parent uses flexbox: this defines minimum width for text "6 results" */
/* parent uses flexbox: this defines minimum width for translated text "noResults" */
flex-basis: 100%;
flex-shrink: 0;
}
@ -65,3 +65,42 @@
.resultsFound {
white-space: nowrap;
}
.searchFiltersPanelClosed {
@apply --marketplaceButtonStylesSecondary;
@apply --marketplaceSearchFilterLabelFontStyles;
display: inline-block;
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);
}
}
.searchFiltersPanelOpen {
@apply --marketplaceButtonStyles;
@apply --marketplaceSearchFilterLabelFontStyles;
font-weight: var(--fontWeightSemiBold);
display: inline-block;
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);
}
}

View file

@ -27,6 +27,9 @@ const SearchFiltersComponent = props => {
searchInProgress,
categories,
amenities,
isSearchFiltersPanelOpen,
toggleSearchFiltersPanel,
searchFiltersPanelSelectedCount,
history,
intl,
} = props;
@ -90,11 +93,29 @@ const SearchFiltersComponent = props => {
/>
) : null;
const toggleSearchFiltersPanelButtonClasses =
isSearchFiltersPanelOpen || searchFiltersPanelSelectedCount > 0
? css.searchFiltersPanelOpen
: css.searchFiltersPanelClosed;
const toggleSearchFiltersPanelButton = toggleSearchFiltersPanel ? (
<button
className={toggleSearchFiltersPanelButtonClasses}
onClick={() => {
toggleSearchFiltersPanel(!isSearchFiltersPanelOpen);
}}
>
<FormattedMessage
id="SearchFilters.moreFiltersButton"
values={{ count: searchFiltersPanelSelectedCount }}
/>
</button>
) : null;
return (
<div className={classes}>
<div className={css.filters}>
{categoryFilter}
{amenitiesFilter}
{toggleSearchFiltersPanelButton}
</div>
{listingsAreLoaded && resultsCount > 0 ? (
@ -127,6 +148,9 @@ SearchFiltersComponent.defaultProps = {
searchingInProgress: false,
categories: null,
amenities: null,
isSearchFiltersPanelOpen: false,
toggleSearchFiltersPanel: null,
searchFiltersPanelSelectedCount: 0,
};
SearchFiltersComponent.propTypes = {
@ -139,6 +163,9 @@ SearchFiltersComponent.propTypes = {
onManageDisableScrolling: func.isRequired,
categories: array,
amenities: array,
isSearchFiltersPanelOpen: bool,
toggleSearchFiltersPanel: func,
searchFiltersPanelSelectedCount: number,
// from withRouter
history: shape({

View file

@ -34,7 +34,7 @@ const hasFilterQueryParams = queryParams => {
class SearchFiltersMobileComponent extends Component {
constructor(props) {
super(props);
this.state = { isFiltersOpenOnMobile: false };
this.state = { isFiltersOpenOnMobile: false, initialQueryParams: null };
this.openFilters = this.openFilters.bind(this);
this.cancelFilters = this.cancelFilters.bind(this);

View file

@ -0,0 +1,66 @@
@import '../../marketplace.css';
.root {
flex-grow: 1;
display: flex;
flex-direction: column;
justify-content: space-between;
background-color: var(--matterColorBright);
}
.filtersWrapper {
}
.footer {
display: flex;
}
.resetAllButton {
@apply --marketplaceH5FontStyles;
font-weight: var(--fontWeightMedium);
color: var(--matterColorAnti);
/* Layout */
margin: 0 auto 0 0;
/* Override button styles */
outline: none;
&:focus,
&:hover {
color: var(--matterColor);
}
}
.cancelButton {
@apply --marketplaceH5FontStyles;
font-weight: var(--fontWeightMedium);
color: var(--matterColorAnti);
/* Layout */
margin: 0 24px 0 24px;
/* Override button styles */
outline: none;
&:focus,
&:hover {
color: var(--matterColor);
}
}
.applyButton {
@apply --marketplaceH5FontStyles;
font-weight: var(--fontWeightMedium);
/* Layout */
margin: 0;
/* Override button styles */
outline: none;
&:focus,
&:hover {
color: var(--marketplaceColorDark);
}
}

View file

@ -0,0 +1,177 @@
/**
* SearchFiltersPanel can be used to add extra filters to togglable panel in SearchPage.
* Using this component will need you to enable it in SearchPage and passing needed props to
* SearchFilters component (which is the default place for SearchFilters).
*
*
* An example how to render MultiSelectFilter inside render function:
*
* const currentQueryParams = this.state.currentQueryParams;
* const splitQueryParam = queryParam => queryParam ? queryParam.split(',') : [];
*
* // initialValue for a select should come either from state.currentQueryParam or urlQueryParam
* const hascurrentQueryParam = typeof currentQueryParams[MULTI_SELECT_URL_PARAM] !== 'undefined'
* const initialMultiSelectValue = hascurrentQueryParam
* ? splitQueryParam(currentQueryParams[MULTI_SELECT_URL_PARAM])
* : splitQueryParam(this.props.urlQueryParams[MULTI_SELECT_URL_PARAM]);
*
* const multiSelectFilterX = multiSelectFilterXFromProps ? (
* <SelectMultipleFilterMobile
* id="SearchFiltersPanel.multiSelectFilterX"
* name="multiSelectFilterX"
* urlParam={MULTI_SELECT_URL_PARAM}
* label={this.props.intl.formatMessage({ id: 'SearchFiltersPanel.multiSelectFilterXLabel' })}
* onSelect={this.handleSelectMultiple}
* options={multiSelectFilterXFromProps}
* initialValues={initialMultiSelectValue}
* twoColumns
* />
* ) : null;
*/
import React, { Component } from 'react';
import { func, object, shape, string } from 'prop-types';
import classNames from 'classnames';
import { FormattedMessage, injectIntl, intlShape } from 'react-intl';
import { withRouter } from 'react-router-dom';
import { omit } from 'lodash';
import routeConfiguration from '../../routeConfiguration';
import { createResourceLocatorString } from '../../util/routes';
import { InlineTextButton } from '../../components';
import css from './SearchFiltersPanel.css';
// Create constants from url params and uset them in FILTERS array and while adding actual filters
// e.g. const MULTI_SELECT_URL_PARAM = 'pub_filterX';
const FILTERS = [];
class SearchFiltersPanelComponent extends Component {
constructor(props) {
super(props);
this.state = { currentQueryParams: props.urlQueryParams };
this.applyFilters = this.applyFilters.bind(this);
this.cancelFilters = this.cancelFilters.bind(this);
this.resetAll = this.resetAll.bind(this);
this.handleSelectSingle = this.handleSelectSingle.bind(this);
this.handleSelectMultiple = this.handleSelectMultiple.bind(this);
}
// Apply the filters by redirecting to SearchPage with new filters.
applyFilters() {
const { history, urlQueryParams, onClosePanel } = this.props;
history.push(
createResourceLocatorString(
'SearchPage',
routeConfiguration(),
{},
{ ...urlQueryParams, ...this.state.currentQueryParams }
)
);
// Ensure that panel closes (if now changes have been made)
onClosePanel();
}
// Close the filters by clicking cancel, revert to the initial params
cancelFilters() {
this.setState({ currentQueryParams: {} });
this.props.onClosePanel();
}
// Reset all filter query parameters
resetAll(e) {
const { urlQueryParams, history, onClosePanel } = this.props;
const queryParams = omit(urlQueryParams, FILTERS);
history.push(createResourceLocatorString('SearchPage', routeConfiguration(), {}, queryParams));
// Ensure that panel closes (if now changes have been made)
onClosePanel();
// blur event target if event is passed
if (e && e.currentTarget) {
e.currentTarget.blur();
}
}
handleSelectSingle(urlParam, option) {
const urlQueryParams = this.props.urlQueryParams;
this.setState(prevState => {
const prevQueryParams = prevState.currentQueryParams;
const mergedQueryParams = { ...urlQueryParams, ...prevQueryParams };
// query parameters after selecting the option
// if no option is passed, clear the selection for the filter
const currentQueryParams = option
? { ...mergedQueryParams, [urlParam]: option }
: omit(mergedQueryParams, urlParam);
return { currentQueryParams };
});
}
handleSelectMultiple(urlParam, options) {
const urlQueryParams = this.props.urlQueryParams;
this.setState(prevState => {
const prevQueryParams = prevState.currentQueryParams;
const mergedQueryParams = { ...urlQueryParams, ...prevQueryParams };
// query parameters after selecting options
// if no option is passed, clear the selection from state.currentQueryParams
const currentQueryParams =
options && options.length > 0
? { ...mergedQueryParams, [urlParam]: options.join(',') }
: { ...mergedQueryParams, [urlParam]: null };
return { currentQueryParams };
});
}
render() {
const { rootClassName, className } = this.props;
const classes = classNames(rootClassName || css.root, className);
return (
<div className={classes}>
<div className={css.filtersWrapper}>{/* Add filters here */}</div>
<div className={css.footer}>
<InlineTextButton className={css.resetAllButton} onClick={this.resetAll}>
<FormattedMessage id={'SearchFiltersPanel.resetAll'} />
</InlineTextButton>
<InlineTextButton className={css.cancelButton} onClick={this.cancelFilters}>
<FormattedMessage id={'SearchFiltersPanel.cancel'} />
</InlineTextButton>
<InlineTextButton className={css.applyButton} onClick={this.applyFilters}>
<FormattedMessage id={'SearchFiltersPanel.apply'} />
</InlineTextButton>
</div>
</div>
);
}
}
SearchFiltersPanelComponent.defaultProps = {
rootClassName: null,
className: null,
};
SearchFiltersPanelComponent.propTypes = {
rootClassName: string,
className: string,
urlQueryParams: object.isRequired,
onClosePanel: func.isRequired,
// from injectIntl
intl: intlShape.isRequired,
// from withRouter
history: shape({
push: func.isRequired,
}).isRequired,
};
const SearchFiltersPanel = injectIntl(withRouter(SearchFiltersPanelComponent));
export default SearchFiltersPanel;

View file

@ -44,6 +44,9 @@
overflow: hidden;
}
.columnLayout {
}
.clearButton {
@apply --marketplaceH5FontStyles;
font-weight: var(--fontWeightMedium);

View file

@ -1,5 +1,5 @@
import React, { Component } from 'react';
import { array, string, func } from 'prop-types';
import { array, bool, func, string } from 'prop-types';
import classNames from 'classnames';
import { injectIntl, intlShape, FormattedMessage } from 'react-intl';
@ -34,7 +34,17 @@ class SelectMultipleFilterMobileComponent extends Component {
}
render() {
const { rootClassName, className, name, label, options, initialValues, intl } = this.props;
const {
rootClassName,
className,
id,
name,
label,
options,
initialValues,
intl,
twoColumns,
} = this.props;
const classes = classNames(rootClassName || css.root, className);
@ -48,9 +58,11 @@ class SelectMultipleFilterMobileComponent extends Component {
)
: label;
const optionsContainerClass = this.state.isOpen
? css.optionsContainerOpen
: css.optionsContainerClosed;
const optionsContainerClass = classNames({
[css.optionsContainerOpen]: this.state.isOpen,
[css.optionsContainerClosed]: !this.state.isOpen,
[css.columnLayout]: twoColumns,
});
const initialValuesObj = arrayToFormValues(initialValues);
const namedInitialValues = { [name]: initialValuesObj };
@ -66,14 +78,15 @@ class SelectMultipleFilterMobileComponent extends Component {
</button>
</div>
<SelectMultipleFilterMobileForm
form={`SelectMultipleFilterMobileForm.${name}`}
form={`SelectMultipleFilterMobileForm.${id ? id : name}`}
className={optionsContainerClass}
name={name}
options={options}
initialValues={namedInitialValues}
onChange={this.handleSelect}
enableReinitialize={true}
keepDirtyOnReinitialize={true}
twoColumns={twoColumns}
enableReinitialize
keepDirtyOnReinitialize
/>
</div>
);
@ -83,18 +96,22 @@ class SelectMultipleFilterMobileComponent extends Component {
SelectMultipleFilterMobileComponent.defaultProps = {
rootClassName: null,
className: null,
id: undefined,
initialValues: [],
twoColumns: false,
};
SelectMultipleFilterMobileComponent.propTypes = {
rootClassName: string,
className: string,
id: string,
name: string.isRequired,
urlParam: string.isRequired,
label: string.isRequired,
onSelect: func.isRequired,
options: array.isRequired,
initialValues: array,
twoColumns: bool,
// from injectIntl
intl: intlShape.isRequired,

View file

@ -1,21 +1,27 @@
import React from 'react';
import { arrayOf, shape, string, node } from 'prop-types';
import { arrayOf, bool, node, shape, string } from 'prop-types';
import { reduxForm, propTypes as formPropTypes } from 'redux-form';
import { FieldGroupCheckbox, Form } from '../../components';
const SelectMultipleFilterMobileFormComponent = props => {
const { form, className, name, options } = props;
const { form, className, name, options, twoColumns } = props;
return (
<Form className={className}>
<FieldGroupCheckbox name={name} id={`${form}.${name}`} options={options} />
<FieldGroupCheckbox
name={name}
id={`${form}.${name}`}
options={options}
twoColumns={twoColumns}
/>
</Form>
);
};
SelectMultipleFilterMobileFormComponent.defaultProps = {
className: null,
twoColumns: false,
};
SelectMultipleFilterMobileFormComponent.propTypes = {
@ -28,6 +34,7 @@ SelectMultipleFilterMobileFormComponent.propTypes = {
label: node.isRequired,
})
).isRequired,
twoColumns: bool,
};
const SelectMultipleFilterMobileForm = reduxForm({})(SelectMultipleFilterMobileFormComponent);

View file

@ -43,6 +43,16 @@
overflow: hidden;
}
.hasBullets {
padding-left: 26px;
}
.twoColumns {
@media (--viewportMedium) {
column-count: 2;
}
}
.optionBorder,
.optionBorderSelected {
position: absolute;
@ -64,6 +74,28 @@
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);
@ -79,6 +111,7 @@
/* Override button styles */
outline: none;
border: none;
cursor: pointer;
&:focus,
&:hover {
@ -90,6 +123,11 @@
}
}
.optionSelected {
composes: option;
color: var(--matterColorDark);
}
.clearButton {
@apply --marketplaceH5FontStyles;
font-weight: var(--fontWeightMedium);

View file

@ -1,5 +1,5 @@
import React, { Component } from 'react';
import { string, func, arrayOf, shape } from 'prop-types';
import { arrayOf, bool, func, shape, string } from 'prop-types';
import classNames from 'classnames';
import { FormattedMessage } from 'react-intl';
@ -28,13 +28,25 @@ class SelectSingleFilterMobile extends Component {
}
render() {
const { rootClassName, className, label, options, initialValue } = this.props;
const {
rootClassName,
className,
label,
options,
initialValue,
twoColumns,
useBullets,
} = this.props;
const labelClass = initialValue ? css.filterLabelSelected : css.filterLabel;
const optionsContainerClass = this.state.isOpen
? css.optionsContainerOpen
: css.optionsContainerClosed;
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);
@ -52,12 +64,21 @@ class SelectSingleFilterMobile extends Component {
{options.map(option => {
// check if this option is selected
const selected = initialValue === option.key;
// menu item border class
const optionBorderClass = selected ? css.optionBorderSelected : css.optionBorder;
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 (
<button
key={option.key}
className={css.option}
className={optionClass}
onClick={() => this.selectOption(option.key)}
>
<span className={optionBorderClass} />
@ -75,6 +96,8 @@ SelectSingleFilterMobile.defaultProps = {
rootClassName: null,
className: null,
initialValue: null,
twoColumns: false,
useBullets: false,
};
SelectSingleFilterMobile.propTypes = {
@ -91,6 +114,8 @@ SelectSingleFilterMobile.propTypes = {
})
).isRequired,
initialValue: string,
twoColumns: bool,
useBullets: bool,
};
export default SelectSingleFilterMobile;

View file

@ -98,6 +98,7 @@ export { default as ReviewRating } from './ReviewRating/ReviewRating';
export { default as Reviews } from './Reviews/Reviews';
export { default as SearchFilters } from './SearchFilters/SearchFilters';
export { default as SearchFiltersMobile } from './SearchFiltersMobile/SearchFiltersMobile';
export { default as SearchFiltersPanel } from './SearchFiltersPanel/SearchFiltersPanel';
export { default as SearchMap } from './SearchMap/SearchMap';
export { default as SearchMapGroupLabel } from './SearchMapGroupLabel/SearchMapGroupLabel';
export { default as SearchMapInfoCard } from './SearchMapInfoCard/SearchMapInfoCard';

View file

@ -37,10 +37,10 @@
flex-grow: 1;
display: flex;
flex-direction: column;
padding: 2px 0 96px 0;
padding: 2px 0 0 0;
@media (--viewportMedium) {
padding: 18px 0 96px 0;
padding: 18px 0 0 0;
/**
* .container is using flexbox,
* This specifies that searchResultContainer is taking 60% from the viewport width
@ -71,6 +71,10 @@
padding: 0 24px;
margin-bottom: 21px;
}
@media (--viewportLarge) {
padding: 0;
}
}
.searchFiltersMobile {
@ -81,16 +85,29 @@
}
}
.searchFiltersPanel {
flex-grow: 1;
display: flex;
padding: 0 24px 24px 24px;
@media (--viewportLarge) {
padding: 0;
}
}
.listings {
flex-grow: 1;
display: flex;
flex-direction: column;
padding: 0 24px;
padding: 0 24px 96px 24px;
@media (--viewportLarge) {
padding: 0 0 96px 0;
}
}
.newSearchInProgress {
opacity: 0.1;
transition: opacity var(--transitionStyleButton);
}
.searchListingsPanel {

View file

@ -26,6 +26,7 @@ import {
SearchResultsPanel,
SearchFilters,
SearchFiltersMobile,
SearchFiltersPanel,
} from '../../components';
import { TopbarContainer } from '../../containers';
import { searchListings, searchMapListings } from './SearchPage.duck';
@ -44,6 +45,7 @@ const BOUNDS_FIXED_PRECISION = 8;
const CATEGORY_URL_PARAM = 'pub_category';
const AMENITIES_URL_PARAM = 'pub_amenities';
const USE_SEARCH_FILTER_PANEL = false;
// Find correct extended data key from config.custom
// e.g. 'pub_category' -> 'categories'.
@ -99,6 +101,7 @@ export class SearchPageComponent extends Component {
this.state = {
isSearchMapOpenOnMobile: props.tab === 'map',
isMobileModalOpen: false,
isSearchFiltersPanelOpen: false,
};
// Initiating map creates 'bounds_changes' event
@ -318,6 +321,51 @@ export class SearchPageComponent extends Component {
this.setState({ isSearchMapOpenOnMobile: true });
};
const extraSearchFiltersPanelOrListings =
USE_SEARCH_FILTER_PANEL && this.state.isSearchFiltersPanelOpen ? (
<div className={classNames(css.searchFiltersPanel)}>
<SearchFiltersPanel
urlQueryParams={validQueryParams}
listingsAreLoaded={listingsAreLoaded}
categories={categories}
amenities={amenities}
onClosePanel={() => this.setState({ isSearchFiltersPanelOpen: false })}
/>
</div>
) : (
<div
className={classNames(css.listings, {
[css.newSearchInProgress]: !listingsAreLoaded,
})}
>
{searchListingsError ? searchError : null}
<SearchResultsPanel
className={css.searchListingsPanel}
listings={listings}
pagination={listingsAreLoaded ? pagination : null}
search={searchParamsForPagination}
/>
</div>
);
// An example how to check how many filters are selected on SearchFilterPanel
// if it is in use.
//
// const searchFiltersPanelSelectedCount = [
// validQueryParams[FILTER_1_URL_PARAM],
// validQueryParams[FILTER_2_URL_PARAM],
// ].filter(param => !!param).length
const searchFiltersPanelSelectedCount = 0;
const searchFiltersPanelProps = USE_SEARCH_FILTER_PANEL
? {
isSearchFiltersPanelOpen: this.state.isSearchFiltersPanelOpen,
toggleSearchFiltersPanel: isOpen => {
this.setState({ isSearchFiltersPanelOpen: isOpen });
},
searchFiltersPanelSelectedCount,
}
: {};
// Set topbar class based on if a modal is open in
// a child component
const topbarClasses = this.state.isMobileModalOpen
@ -353,6 +401,7 @@ export class SearchPageComponent extends Component {
onManageDisableScrolling={onManageDisableScrolling}
categories={categories}
amenities={amenities}
{...searchFiltersPanelProps}
/>
<SearchFiltersMobile
className={css.searchFiltersMobile}
@ -369,20 +418,7 @@ export class SearchPageComponent extends Component {
categories={categories}
amenities={amenities}
/>
<div
className={classNames(css.listings, {
[css.newSearchInProgress]: !listingsAreLoaded,
})}
>
{searchListingsError ? searchError : null}
<SearchResultsPanel
className={css.searchListingsPanel}
listings={listings}
pagination={listingsAreLoaded ? pagination : null}
search={searchParamsForPagination}
/>
</div>
{extraSearchFiltersPanelOrListings}
</div>
<ModalInMobile
className={css.mapPanel}

View file

@ -491,6 +491,7 @@
"SearchFilters.foundResults": "{count, number} {count, plural, one {result} other {results}}",
"SearchFilters.loadingResults": "Loading search results…",
"SearchFilters.loadingResultsMobile": "Loading…",
"SearchFilters.moreFiltersButton": "{count, plural, =0 {More filters} other {More filters • #}}",
"SearchFilters.noResults": "Could not find any listings.",
"SearchFilters.noResultsMobile": "No results.",
"SearchFilters.openMapView": "Map",
@ -500,6 +501,11 @@
"SearchFiltersMobile.heading": "Filter saunas",
"SearchFiltersMobile.resetAll": "Reset all",
"SearchFiltersMobile.showListings": "Show {count, number} {count, plural, one {sauna} other {saunas}}",
"SearchFiltersPanel.amenitiesLabel": "Multiselect",
"SearchFiltersPanel.apply": "Apply",
"SearchFiltersPanel.cancel": "Cancel",
"SearchFiltersPanel.categoryLabel": "Singleselect",
"SearchFiltersPanel.resetAll": "Reset all",
"SearchMapInfoCard.noImage": "No image",
"SearchPage.schemaDescription": "Showing search results",
"SearchPage.schemaMapSearch": "map search",