flex-template-web/src/containers/SearchPage/SearchPage.js
2018-08-02 00:11:48 +03:00

382 lines
13 KiB
JavaScript

import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from 'react-intl';
import { connect } from 'react-redux';
import { compose } from 'redux';
import { withRouter } from 'react-router-dom';
import debounce from 'lodash/debounce';
import isEqual from 'lodash/isEqual';
import unionWith from 'lodash/unionWith';
import classNames from 'classnames';
import config from '../../config';
import routeConfiguration from '../../routeConfiguration';
import {
googleLatLngToSDKLatLng,
googleBoundsToSDKBounds,
sdkBoundsToFixedCoordinates,
hasSameSDKBounds,
} from '../../util/googleMaps';
import { createResourceLocatorString } from '../../util/routes';
import { parse, stringify } from '../../util/urlHelpers';
import { propTypes } from '../../util/types';
import { getListingsById } from '../../ducks/marketplaceData.duck';
import { manageDisableScrolling, isScrollingDisabled } from '../../ducks/UI.duck';
import { SearchMap, ModalInMobile, Page } from '../../components';
import { TopbarContainer } from '../../containers';
import { searchListings, searchMapListings, setActiveListing } from './SearchPage.duck';
import {
pickSearchParamsOnly,
validURLParamsForExtendedData,
validFilterParams,
createSearchResultSchema,
} from './SearchPage.helpers';
import MainPanel from './MainPanel';
import css from './SearchPage.css';
// Pagination page size might need to be dynamic on responsive page layouts
// Current design has max 3 columns 12 is divisible by 2 and 3
// So, there's enough cards to fill all columns on full pagination pages
const RESULT_PAGE_SIZE = 24;
const MODAL_BREAKPOINT = 768; // Search is in modal on mobile layout
const SEARCH_WITH_MAP_DEBOUNCE = 300; // Little bit of debounce before search is initiated.
const BOUNDS_FIXED_PRECISION = 8;
export class SearchPageComponent extends Component {
constructor(props) {
super(props);
this.state = {
isSearchMapOpenOnMobile: props.tab === 'map',
isMobileModalOpen: false,
};
// Initiating map creates 'bounds_changes' event
// we listen to that event to make new searches
// So, if the search comes from location search input (this.viewportBounds == null),
// we need to by pass extra searches created by Google Map's 'indle' event.
// This is done by keeping track of map's viewport bounds (which differ from location bounds)
this.viewportBounds = null;
this.modalOpenedBoundsChange = false;
this.searchMapListingsInProgress = false;
this.filters = this.filters.bind(this);
this.onIdle = debounce(this.onIdle.bind(this), SEARCH_WITH_MAP_DEBOUNCE);
this.onOpenMobileModal = this.onOpenMobileModal.bind(this);
this.onCloseMobileModal = this.onCloseMobileModal.bind(this);
}
filters() {
const { categories, amenities } = this.props;
return {
categoryFilter: {
paramName: 'pub_category',
options: categories,
},
amenitiesFilter: {
paramName: 'pub_amenities',
options: amenities,
},
};
}
componentWillReceiveProps(nextProps) {
if (!isEqual(this.props.location, nextProps.location)) {
// If no mapSearch url parameter is given, this is original location search
const { mapSearch } = parse(nextProps.location.search, {
latlng: ['origin'],
latlngBounds: ['bounds'],
});
if (!mapSearch) {
this.viewportBounds = null;
}
}
}
// We are using Google Maps idle event instead of bounds_changed, since it will not be fired
// too often (in the middle of map's pan or zoom activity)
onIdle(googleMap) {
const { history, location } = this.props;
// parse query parameters, including a custom attribute named category
const { address, country, bounds, mapSearch, ...rest } = parse(location.search, {
latlng: ['origin'],
latlngBounds: ['bounds'],
});
const viewportGMapBounds = googleMap.getBounds();
const viewportBounds = sdkBoundsToFixedCoordinates(
googleBoundsToSDKBounds(viewportGMapBounds),
BOUNDS_FIXED_PRECISION
);
// ViewportBounds from (previous) rendering differ from viewportBounds currently set to map
// I.e. user has changed the map somehow: moved, panned, zoomed, resized
const viewportBoundsChanged =
this.viewportBounds && !hasSameSDKBounds(this.viewportBounds, viewportBounds);
// If mapSearch url param is given (and we have not just opened mobile map modal)
// or original location search is rendered once,
// we start to react to 'bounds_changed' event by generating new searches
if (viewportBoundsChanged && !this.modalOpenedBoundsChange) {
const originMaybe = config.sortSearchByDistance
? { origin: googleLatLngToSDKLatLng(viewportGMapBounds.getCenter()) }
: {};
const searchParams = {
address,
...originMaybe,
bounds: viewportBounds,
country,
mapSearch: true,
...validFilterParams(rest, this.filters()),
};
this.viewportBounds = viewportBounds;
history.push(
createResourceLocatorString('SearchPage', routeConfiguration(), {}, searchParams)
);
} else {
this.viewportBounds = viewportBounds;
this.modalOpenedBoundsChange = false;
}
}
// Invoked when a modal is opened from a child component,
// for example when a filter modal is opened in mobile view
onOpenMobileModal() {
this.setState({ isMobileModalOpen: true });
}
// Invoked when a modal is closed from a child component,
// for example when a filter modal is opened in mobile view
onCloseMobileModal() {
this.setState({ isMobileModalOpen: false });
}
render() {
const {
intl,
listings,
location,
mapListings,
onManageDisableScrolling,
pagination,
scrollingDisabled,
searchInProgress,
searchListingsError,
searchParams,
activeListingId,
onActivateListing,
} = this.props;
// eslint-disable-next-line no-unused-vars
const { mapSearch, page, ...searchInURL } = parse(location.search, {
latlng: ['origin'],
latlngBounds: ['bounds'],
});
const filters = this.filters();
// urlQueryParams doesn't contain page specific url params
// like mapSearch, page or origin (origin depends on config.sortSearchByDistance)
const urlQueryParams = pickSearchParamsOnly(searchInURL, filters);
// Page transition might initially use values from previous search
const urlQueryString = stringify(urlQueryParams);
const paramsQueryString = stringify(pickSearchParamsOnly(searchParams, filters));
const searchParamsAreInSync = urlQueryString === paramsQueryString;
const validQueryParams = validURLParamsForExtendedData(searchInURL, filters);
const isWindowDefined = typeof window !== 'undefined';
const isMobileLayout = isWindowDefined && window.innerWidth < MODAL_BREAKPOINT;
const shouldShowSearchMap =
!isMobileLayout || (isMobileLayout && this.state.isSearchMapOpenOnMobile);
const onMapIconClick = () => {
this.useLocationSearchBounds = true;
this.modalOpenedBoundsChange = true;
this.setState({ isSearchMapOpenOnMobile: true });
};
const { address, bounds, origin } = searchInURL || {};
const { title, description, schema } = createSearchResultSchema(listings, address, intl);
// Set topbar class based on if a modal is open in
// a child component
const topbarClasses = this.state.isMobileModalOpen
? classNames(css.topbarBehindModal, css.topbar)
: css.topbar;
// N.B. openMobileMap button is sticky.
// For some reason, stickyness doesn't work on Safari, if the element is <button>
/* eslint-disable jsx-a11y/no-static-element-interactions */
return (
<Page
scrollingDisabled={scrollingDisabled}
description={description}
title={title}
schema={schema}
>
<TopbarContainer
className={topbarClasses}
currentPage="SearchPage"
currentSearchParams={urlQueryParams}
/>
<div className={css.container}>
<MainPanel
urlQueryParams={validQueryParams}
listings={listings}
searchInProgress={searchInProgress}
searchListingsError={searchListingsError}
searchParamsAreInSync={searchParamsAreInSync}
onActivateListing={onActivateListing}
onManageDisableScrolling={onManageDisableScrolling}
onOpenModal={this.onOpenMobileModal}
onCloseModal={this.onCloseMobileModal}
onMapIconClick={onMapIconClick}
pagination={pagination}
searchParamsForPagination={parse(location.search)}
showAsModalMaxWidth={MODAL_BREAKPOINT}
primaryFilters={{
categoryFilter: filters.categoryFilter,
amenitiesFilter: filters.amenitiesFilter,
}}
/>
<ModalInMobile
className={css.mapPanel}
id="SearchPage.map"
isModalOpenOnMobile={this.state.isSearchMapOpenOnMobile}
onClose={() => this.setState({ isSearchMapOpenOnMobile: false })}
showAsModalMaxWidth={MODAL_BREAKPOINT}
onManageDisableScrolling={onManageDisableScrolling}
>
<div className={css.map}>
{shouldShowSearchMap ? (
<SearchMap
activeListingId={activeListingId}
bounds={bounds}
center={origin}
listings={mapListings || []}
onIdle={this.onIdle}
isOpenOnModal={this.state.isSearchMapOpenOnMobile}
onCloseAsModal={() => {
onManageDisableScrolling('SearchPage.map', false);
}}
useLocationSearchBounds={!this.viewportBounds}
/>
) : null}
</div>
</ModalInMobile>
</div>
</Page>
);
/* eslint-enable jsx-a11y/no-static-element-interactions */
}
}
SearchPageComponent.defaultProps = {
listings: [],
mapListings: [],
pagination: null,
searchListingsError: null,
searchParams: {},
tab: 'listings',
categories: config.custom.categories,
amenities: config.custom.amenities,
activeListingId: null,
};
const { array, bool, func, oneOf, object, shape, string } = PropTypes;
SearchPageComponent.propTypes = {
listings: array,
mapListings: array,
onActivateListing: func.isRequired,
onManageDisableScrolling: func.isRequired,
onSearchMapListings: func.isRequired,
pagination: propTypes.pagination,
scrollingDisabled: bool.isRequired,
searchInProgress: bool.isRequired,
searchListingsError: propTypes.error,
searchParams: object,
tab: oneOf(['filters', 'listings', 'map']).isRequired,
categories: array,
amenities: array,
// from withRouter
history: shape({
push: func.isRequired,
}).isRequired,
location: shape({
search: string.isRequired,
}).isRequired,
// from injectIntl
intl: intlShape.isRequired,
};
const mapStateToProps = state => {
const {
currentPageResultIds,
pagination,
searchInProgress,
searchListingsError,
searchParams,
searchMapListingIds,
activeListingId,
} = state.SearchPage;
const pageListings = getListingsById(state, currentPageResultIds);
const mapListings = getListingsById(
state,
unionWith(currentPageResultIds, searchMapListingIds, (id1, id2) => id1.uuid === id2.uuid)
);
return {
listings: pageListings,
mapListings,
pagination,
scrollingDisabled: isScrollingDisabled(state),
searchInProgress,
searchListingsError,
searchParams,
activeListingId,
};
};
const mapDispatchToProps = dispatch => ({
onManageDisableScrolling: (componentId, disableScrolling) =>
dispatch(manageDisableScrolling(componentId, disableScrolling)),
onSearchMapListings: searchParams => dispatch(searchMapListings(searchParams)),
onActivateListing: listingId => dispatch(setActiveListing(listingId)),
});
// Note: it is important that the withRouter HOC is **outside** the
// connect HOC, otherwise React Router won't rerender any Route
// components since connect implements a shouldComponentUpdate
// lifecycle hook.
//
// See: https://github.com/ReactTraining/react-router/issues/4671
const SearchPage = compose(withRouter, connect(mapStateToProps, mapDispatchToProps), injectIntl)(
SearchPageComponent
);
SearchPage.loadData = (params, search) => {
const queryParams = parse(search, {
latlng: ['origin'],
latlngBounds: ['bounds'],
});
const { page = 1, address, country, origin, ...rest } = queryParams;
const originMaybe = config.sortSearchByDistance && origin ? { origin } : {};
return searchListings({
...rest,
...originMaybe,
page,
perPage: RESULT_PAGE_SIZE,
include: ['author', 'images'],
'fields.image': ['variants.landscape-crop', 'variants.landscape-crop2x'],
'limit.images': 1,
});
};
export default SearchPage;