SearchPage

This commit is contained in:
Vesa Luusua 2017-07-18 18:30:29 +03:00
parent 06a9da19d0
commit 798f71edfe
5 changed files with 348 additions and 140 deletions

View file

@ -1,10 +1,44 @@
@import '../../marketplace.css';
:root {
--containerHeight: calc(100vh - var(--topbarHeightDesktop));
}
.topbar {
@media (--desktopViewport) {
position: fixed;
top: 0;
left: 0;
/* We need to raise Topbar above .container */
z-index: 1;
}
}
.container {
/* Layout */
width: 100%;
flex-grow: 1;
display: flex;
@media (--desktopViewport) {
position: relative;
padding-top: var(--topbarHeightDesktop);
min-height: var(--containerHeight);
}
}
.searchResultContainer {
flex-grow: 1;
display: flex;
flex-direction: column;
@media (--desktopViewport) {
/**
* .container is using flexbox,
* This specifies that searchResultContainer is taking 60% from the viewport width
*/
flex-basis: 60%;
}
}
.searchResultSummary {
@ -17,6 +51,7 @@
}
.searchString {
/* Search string should not break on white spaces - i.e. line-break should happen before. */
white-space: nowrap;
}
@ -29,3 +64,33 @@
.searchListingsPanel {
flex-grow: 1;
}
.mapPanel {
display: none;
@media (--desktopViewport) {
/**
* .container is using flexbox,
* This specifies that mapPanel is taking 40% from the viewport width
*/
flex-basis: 40%;
/* Own layout settings */
display: flex;
height: 100%;
}
}
.map {
@media (--desktopViewport) {
/* Map is fixed so that it doesn't scroll along search results */
position: fixed;
top: var(--topbarHeightDesktop);
right: 0;
/* Fixed content needs width relative to viewport */
width: 40vw;
height: var(--containerHeight);
}
}

View file

@ -6,6 +6,10 @@ export const SEARCH_LISTINGS_REQUEST = 'app/SearchPage/SEARCH_LISTINGS_REQUEST';
export const SEARCH_LISTINGS_SUCCESS = 'app/SearchPage/SEARCH_LISTINGS_SUCCESS';
export const SEARCH_LISTINGS_ERROR = 'app/SearchPage/SEARCH_LISTINGS_ERROR';
export const SEARCH_MAP_LISTINGS_REQUEST = 'app/SearchPage/SEARCH_MAP_LISTINGS_REQUEST';
export const SEARCH_MAP_LISTINGS_SUCCESS = 'app/SearchPage/SEARCH_MAP_LISTINGS_SUCCESS';
export const SEARCH_MAP_LISTINGS_ERROR = 'app/SearchPage/SEARCH_MAP_LISTINGS_ERROR';
// ================ Reducer ================ //
const initialState = {
@ -14,6 +18,8 @@ const initialState = {
searchInProgress: false,
searchListingsError: null,
currentPageResultIds: [],
searchMapListingIds: [],
searchMapListingsError: null,
};
const resultIds = data => data.data.map(l => l.id);
@ -40,6 +46,23 @@ const listingPageReducer = (state = initialState, action = {}) => {
// eslint-disable-next-line no-console
console.error(payload);
return { ...state, searchInProgress: false, searchListingsError: payload };
case SEARCH_MAP_LISTINGS_REQUEST:
return {
...state,
//searchMapListingIds: [],
searchMapListingsError: null,
};
case SEARCH_MAP_LISTINGS_SUCCESS:
return {
...state,
searchMapListingIds: state.searchMapListingIds.concat(resultIds(payload.data)),
};
case SEARCH_MAP_LISTINGS_ERROR:
// eslint-disable-next-line no-console
console.error(payload);
return { ...state, searchMapListingsError: payload };
default:
return state;
}
@ -65,6 +88,19 @@ export const searchListingsError = e => ({
payload: e,
});
export const searchMapListingsRequest = () => ({ type: SEARCH_MAP_LISTINGS_REQUEST });
export const searchMapListingsSuccess = response => ({
type: SEARCH_MAP_LISTINGS_SUCCESS,
payload: { data: response.data },
});
export const searchMapListingsError = e => ({
type: SEARCH_MAP_LISTINGS_ERROR,
error: true,
payload: e,
});
export const searchListings = searchParams =>
(dispatch, getState, sdk) => {
dispatch(searchListingsRequest(searchParams));
@ -87,3 +123,26 @@ export const searchListings = searchParams =>
throw e;
});
};
export const searchMapListings = searchParams =>
(dispatch, getState, sdk) => {
dispatch(searchMapListingsRequest(searchParams));
const { origin, include = [], page = 1, perPage } = searchParams;
// TODO: API can't handle camelCase request parameter yet.
const searchOrQuery = origin
? sdk.listings.search({ ...searchParams, page, per_page: perPage })
: sdk.listings.query({ include, page, per_page: perPage });
return searchOrQuery
.then(response => {
dispatch(addMarketplaceEntities(response));
dispatch(searchMapListingsSuccess(response));
return response;
})
.catch(e => {
dispatch(searchMapListingsError(e));
throw e;
});
};

View file

@ -1,145 +1,193 @@
import React, { PropTypes } from 'react';
import React, { Component, PropTypes } from 'react';
import { FormattedMessage } from 'react-intl';
import { connect } from 'react-redux';
import { compose } from 'redux';
import { withRouter } from 'react-router-dom';
import { unionWith } from 'lodash';
import config from '../../config';
import { parse, stringify } from '../../util/urlHelpers';
import * as propTypes from '../../util/propTypes';
import { getListingsById } from '../../ducks/marketplaceData.duck';
import { logout, authenticationInProgress } from '../../ducks/Auth.duck';
import { manageDisableScrolling, isScrollingDisabled } from '../../ducks/UI.duck';
import { PageLayout, SearchResultsPanel, Topbar } from '../../components';
import { searchListings } from './SearchPage.duck';
import { SearchMap, ModalInMobile, PageLayout, SearchResultsPanel, Topbar } from '../../components';
import { searchListings, searchMapListings } from './SearchPage.duck';
import css from './SearchPage.css';
// TODO Pagination page size might need to be dynamic on responsive page layouts
const RESULT_PAGE_SIZE = 12;
const SHARETRIBE_API_MAX_PAGE_SIZE = 100;
const MAX_SEARCH_RESULT_PAGES_ON_MAP = 5;
// 100 * 5 = 500 listings are shown on a map.
const pickSearchParamsOnly = params => {
const { address, origin, bounds } = params || {};
return { address, origin, bounds };
};
export const SearchPageComponent = props => {
const {
authInfoError,
authInProgress,
currentUser,
currentUserHasListings,
history,
isAuthenticated,
listings,
location,
logoutError,
notificationCount,
onLogout,
onManageDisableScrolling,
pagination,
scrollingDisabled,
searchInProgress,
searchListingsError,
searchParams,
tab,
} = props;
export class SearchPageComponent extends Component {
componentDidMount() {
const { location, onSearchMapListings } = this.props;
const searchInURL = parse(location.search, {
latlng: ['origin'],
latlngBounds: ['bounds'],
});
const perPage = SHARETRIBE_API_MAX_PAGE_SIZE;
const page = 1;
const searchParamsForMapResults = { ...searchInURL, page, perPage };
// eslint-disable-next-line no-unused-vars
const { page, ...searchInURL } = parse(location.search, {
latlng: ['origin'],
latlngBounds: ['bounds'],
});
// Search more listings
onSearchMapListings(searchParamsForMapResults)
.then(response => {
const hasNextPage = page < response.data.meta.totalPages &&
page < MAX_SEARCH_RESULT_PAGES_ON_MAP;
if (hasNextPage) {
onSearchMapListings({ ...searchParamsForMapResults, page: page + 1 });
}
})
.catch(error => {
// In case of error, stop recursive loop and report error.
console.error(`An error (${error} occured while trying to retrieve map listings`);
});
}
// Page transition might initially use values from previous search
const searchParamsInURL = stringify(pickSearchParamsOnly(searchInURL));
const searchParamsInProps = stringify(pickSearchParamsOnly(searchParams));
const searchParamsMatch = searchParamsInURL === searchParamsInProps;
render() {
const {
authInfoError,
authInProgress,
currentUser,
currentUserHasListings,
history,
isAuthenticated,
listings,
location,
logoutError,
mapListings,
notificationCount,
onLogout,
onManageDisableScrolling,
pagination,
scrollingDisabled,
searchInProgress,
searchListingsError,
searchParams,
tab,
} = this.props;
const hasPaginationInfo = !!pagination && pagination.totalItems != null;
const totalItems = searchParamsMatch && hasPaginationInfo ? pagination.totalItems : 0;
const listingsAreLoaded = !searchInProgress && searchParamsMatch && hasPaginationInfo;
// eslint-disable-next-line no-unused-vars
const { page, ...searchInURL } = parse(location.search, {
latlng: ['origin'],
latlngBounds: ['bounds'],
});
const searchError = (
<h2 className={css.error}>
<FormattedMessage id="SearchPage.searchError" />
</h2>
);
// Page transition might initially use values from previous search
const searchParamsInURL = stringify(pickSearchParamsOnly(searchInURL));
const searchParamsInProps = stringify(pickSearchParamsOnly(searchParams));
const searchParamsMatch = searchParamsInURL === searchParamsInProps;
const resultsFoundNoAddress = (
<h2>
<FormattedMessage id="SearchPage.foundResults" values={{ count: totalItems }} />
</h2>
);
const address = searchInURL && searchInURL.address
? <span className={css.searchString}>{searchInURL.address.split(', ')[0]}</span>
: null;
const resultsFoundWithAddress = (
<h2>
<FormattedMessage
id="SearchPage.foundResultsWithAddress"
values={{ count: totalItems, address }}
/>
</h2>
);
const resultsFound = address ? resultsFoundWithAddress : resultsFoundNoAddress;
const { address, bounds, origin } = searchInURL || {};
const noResults = (
<h2>
<FormattedMessage id="SearchPage.noResults" />
</h2>
);
const hasPaginationInfo = !!pagination && pagination.totalItems != null;
const totalItems = searchParamsMatch && hasPaginationInfo ? pagination.totalItems : 0;
const listingsAreLoaded = !searchInProgress && searchParamsMatch && hasPaginationInfo;
const loadingResults = (
<h2>
<FormattedMessage id="SearchPage.loadingResults" />
</h2>
);
const searchError = (
<h2 className={css.error}>
<FormattedMessage id="SearchPage.searchError" />
</h2>
);
const searchParamsForPagination = parse(location.search);
const resultsFoundNoAddress = (
<h2>
<FormattedMessage id="SearchPage.foundResults" values={{ count: totalItems }} />
</h2>
);
const addressNode = address
? <span className={css.searchString}>{searchInURL.address.split(', ')[0]}</span>
: null;
const resultsFoundWithAddress = (
<h2>
<FormattedMessage
id="SearchPage.foundResultsWithAddress"
values={{ count: totalItems, address: addressNode }}
/>
</h2>
);
const resultsFound = address ? resultsFoundWithAddress : resultsFoundNoAddress;
return (
<PageLayout
authInfoError={authInfoError}
logoutError={logoutError}
title={`Search page: ${tab}`}
scrollingDisabled={scrollingDisabled}
>
<Topbar
isAuthenticated={isAuthenticated}
authInProgress={authInProgress}
currentUser={currentUser}
currentUserHasListings={currentUserHasListings}
notificationCount={notificationCount}
history={history}
location={location}
onLogout={onLogout}
onManageDisableScrolling={onManageDisableScrolling}
/>
<div className={css.searchResultSummary}>
{searchListingsError ? searchError : null}
{listingsAreLoaded && totalItems > 0 ? resultsFound : null}
{listingsAreLoaded && totalItems === 0 ? noResults : null}
{searchInProgress ? loadingResults : null}
</div>
<div className={css.container}>
<div className={css.listings}>
<SearchResultsPanel
className={css.searchListingsPanel}
currencyConfig={config.currencyConfig}
listings={listingsAreLoaded ? listings : []}
pagination={listingsAreLoaded ? pagination : null}
search={searchParamsForPagination}
/>
const noResults = (
<h2>
<FormattedMessage id="SearchPage.noResults" />
</h2>
);
const loadingResults = (
<h2>
<FormattedMessage id="SearchPage.loadingResults" />
</h2>
);
const searchParamsForPagination = parse(location.search);
return (
<PageLayout
authInfoError={authInfoError}
logoutError={logoutError}
title={`Search page: ${tab}`}
scrollingDisabled={scrollingDisabled}
>
<Topbar
className={css.topbar}
isAuthenticated={isAuthenticated}
authInProgress={authInProgress}
currentUser={currentUser}
currentUserHasListings={currentUserHasListings}
notificationCount={notificationCount}
history={history}
location={location}
onLogout={onLogout}
onManageDisableScrolling={onManageDisableScrolling}
/>
<div className={css.container}>
<div className={css.searchResultContainer}>
<div className={css.searchResultSummary}>
{searchListingsError ? searchError : null}
{listingsAreLoaded && totalItems > 0 ? resultsFound : null}
{listingsAreLoaded && totalItems === 0 ? noResults : null}
{searchInProgress ? loadingResults : null}
</div>
<div className={css.listings}>
<SearchResultsPanel
className={css.searchListingsPanel}
currencyConfig={config.currencyConfig}
listings={listingsAreLoaded ? listings : []}
pagination={listingsAreLoaded ? pagination : null}
search={searchParamsForPagination}
/>
</div>
</div>
<ModalInMobile
className={css.mapPanel}
id="SearchPage.map"
isModalOpenOnMobile={false}
onManageDisableScrolling={onManageDisableScrolling}
>
<div className={css.map}>
<SearchMap bounds={bounds} center={origin} listings={mapListings || []} />
</div>
</ModalInMobile>
</div>
</div>
</PageLayout>
);
};
</PageLayout>
);
}
}
SearchPageComponent.defaultProps = {
authInfoError: null,
currentUser: null,
listings: [],
logoutError: null,
mapListings: [],
notificationCount: 0,
pagination: null,
searchListingsError: null,
@ -156,10 +204,12 @@ SearchPageComponent.propTypes = {
currentUserHasListings: bool.isRequired,
isAuthenticated: bool.isRequired,
listings: array,
mapListings: array,
logoutError: instanceOf(Error),
notificationCount: number,
onLogout: func.isRequired,
onManageDisableScrolling: func.isRequired,
onSearchMapListings: func.isRequired,
pagination: propTypes.pagination,
scrollingDisabled: bool.isRequired,
searchInProgress: bool.isRequired,
@ -183,6 +233,7 @@ const mapStateToProps = state => {
searchInProgress,
searchListingsError,
searchParams,
searchMapListingIds,
} = state.SearchPage;
const { authInfoError, isAuthenticated, logoutError } = state.Auth;
const {
@ -190,10 +241,17 @@ const mapStateToProps = state => {
currentUserHasListings,
currentUserNotificationCount: notificationCount,
} = state.user;
const pageListings = getListingsById(state, currentPageResultIds);
const mapListings = getListingsById(
state,
unionWith(currentPageResultIds, searchMapListingIds, (id1, id2) => id1.uuid === id2.uuid)
);
return {
authInfoError,
logoutError,
listings: getListingsById(state, currentPageResultIds),
listings: pageListings,
mapListings,
pagination,
searchInProgress,
searchListingsError,
@ -211,6 +269,7 @@ const mapDispatchToProps = dispatch => ({
onLogout: historyPush => dispatch(logout(historyPush)),
onManageDisableScrolling: (componentId, disableScrolling) =>
dispatch(manageDisableScrolling(componentId, disableScrolling)),
onSearchMapListings: searchParams => dispatch(searchMapListings(searchParams)),
});
const SearchPage = compose(connect(mapStateToProps, mapDispatchToProps), withRouter)(

View file

@ -37,6 +37,7 @@ describe('SearchPageComponent', () => {
isAuthenticated: false,
onLogout: noop,
onManageDisableScrolling: noop,
onSearchMapListings: noop,
};
const tree = renderShallow(<SearchPageComponent {...props} />);
expect(tree).toMatchSnapshot();

View file

@ -22,44 +22,68 @@ exports[`SearchPageComponent matches snapshot 1`] = `
notificationCount={0}
onLogout={[Function]}
onManageDisableScrolling={[Function]} />
<div>
<h2>
<FormattedMessage
id="SearchPage.foundResults"
values={
Object {
"count": 22,
}
} />
</h2>
</div>
<div>
<div>
<SearchResultsPanel
className={null}
currencyConfig={
Object {
"currency": "USD",
"currencyDisplay": "symbol",
"maximumFractionDigits": 2,
"minimumFractionDigits": 2,
"style": "currency",
"subUnitDivisor": 100,
"useGrouping": true,
<div>
<h2>
<FormattedMessage
id="SearchPage.foundResults"
values={
Object {
"count": 22,
}
} />
</h2>
</div>
<div>
<SearchResultsPanel
className={null}
currencyConfig={
Object {
"currency": "USD",
"currencyDisplay": "symbol",
"maximumFractionDigits": 2,
"minimumFractionDigits": 2,
"style": "currency",
"subUnitDivisor": 100,
"useGrouping": true,
}
}
}
listings={Array []}
pagination={
Object {
"page": 1,
"perPage": 12,
"totalItems": 22,
"totalPages": 2,
listings={Array []}
pagination={
Object {
"page": 1,
"perPage": 12,
"totalItems": 22,
"totalPages": 2,
}
}
}
rootClassName={null}
search={Object {}} />
rootClassName={null}
search={Object {}} />
</div>
</div>
<ModalInMobile
className=""
id="SearchPage.map"
isModalOpenOnMobile={false}
onClose={null}
onManageDisableScrolling={[Function]}
showAsModalMaxWidth={0}>
<div>
<SearchMap
bounds={null}
center={
LatLng {
"lat": 0,
"lng": 0,
}
}
className=""
listings={Array []}
rootClassName={null}
zoom={11} />
</div>
</ModalInMobile>
</div>
</withRouter(PageLayout)>
`;