mirror of
https://github.com/kingomarnajjar/flex-template-web.git
synced 2026-07-28 12:43:11 +10:00
Merge pull request #77 from sharetribe/landing-page-form
Proper location search from landing page
This commit is contained in:
commit
2dbe2a7ed1
22 changed files with 512 additions and 307 deletions
|
|
@ -13,6 +13,7 @@
|
|||
"lodash": "^4.17.4",
|
||||
"path-to-regexp": "^1.5.3",
|
||||
"qs": "^6.4.0",
|
||||
"query-string": "^4.3.2",
|
||||
"react": "^15.4.2",
|
||||
"react-dom": "^15.4.2",
|
||||
"react-helmet": "^4.0.0",
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ const url = require('url');
|
|||
const { matchPathname, configureStore } = require('./importer');
|
||||
|
||||
exports.loadData = function(requestUrl, sdk) {
|
||||
const { pathname, query } = url.parse(requestUrl, true);
|
||||
const { pathname, query } = url.parse(requestUrl);
|
||||
const matchedRoutes = matchPathname(pathname);
|
||||
|
||||
const store = configureStore(sdk);
|
||||
|
|
|
|||
|
|
@ -15,12 +15,12 @@ class RouteComponentRenderer extends Component {
|
|||
this.canShowComponent = this.canShowComponent.bind(this);
|
||||
}
|
||||
componentDidMount() {
|
||||
const { match, route, dispatch } = this.props;
|
||||
const { match, location, route, dispatch } = this.props;
|
||||
const { loadData, name } = route;
|
||||
const shouldLoadData = typeof loadData === 'function' && this.canShowComponent();
|
||||
|
||||
if (shouldLoadData) {
|
||||
dispatch(loadData(match.params, {}))
|
||||
dispatch(loadData(match.params, location.search))
|
||||
.then(() => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`loadData success for ${name} route`);
|
||||
|
|
@ -37,26 +37,33 @@ class RouteComponentRenderer extends Component {
|
|||
return !auth || isAuthenticated;
|
||||
}
|
||||
render() {
|
||||
const { route, match, location, staticContext } = this.props;
|
||||
const { route, match, location, staticContext, flattenedRoutes } = this.props;
|
||||
const { component: RouteComponent } = route;
|
||||
const canShow = this.canShowComponent();
|
||||
if (!canShow) {
|
||||
staticContext.forbidden = true;
|
||||
}
|
||||
return canShow
|
||||
? <RouteComponent params={match.params} location={location} />
|
||||
? <RouteComponent
|
||||
params={match.params}
|
||||
location={location}
|
||||
flattenedRoutes={flattenedRoutes}
|
||||
/>
|
||||
: <NamedRedirect name="LogInPage" state={{ from: match.url }} />;
|
||||
}
|
||||
}
|
||||
|
||||
RouteComponentRenderer.propTypes = {
|
||||
isAuthenticated: bool.isRequired,
|
||||
flattenedRoutes: any.isRequired,
|
||||
route: propTypes.route.isRequired,
|
||||
match: shape({
|
||||
params: object.isRequired,
|
||||
url: string.isRequired,
|
||||
}).isRequired,
|
||||
location: any.isRequired,
|
||||
location: shape({
|
||||
search: string.isRequired,
|
||||
}).isRequired,
|
||||
staticContext: object.isRequired,
|
||||
dispatch: func.isRequired,
|
||||
};
|
||||
|
|
@ -65,7 +72,7 @@ const Routes = props => {
|
|||
const { isAuthenticated, flattenedRoutes, staticContext, dispatch } = props;
|
||||
|
||||
const toRouteComponent = route => {
|
||||
const renderProps = { isAuthenticated, route, staticContext, dispatch };
|
||||
const renderProps = { isAuthenticated, route, staticContext, dispatch, flattenedRoutes };
|
||||
return (
|
||||
<Route
|
||||
key={route.name}
|
||||
|
|
|
|||
|
|
@ -12,9 +12,11 @@ const render = (url, context) => {
|
|||
|
||||
describe('Application', () => {
|
||||
it('renders in the client without crashing', () => {
|
||||
window.google = { maps: {} };
|
||||
const store = configureStore({});
|
||||
const div = document.createElement('div');
|
||||
ReactDOM.render(<ClientApp store={store} />, div);
|
||||
delete window.google;
|
||||
});
|
||||
|
||||
it('renders in the server without crashing', () => {
|
||||
|
|
|
|||
|
|
@ -3,13 +3,13 @@
|
|||
}
|
||||
|
||||
.input {
|
||||
height: 30px;
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
.predictions {
|
||||
position: absolute;
|
||||
margin: 0;
|
||||
top: 30px;
|
||||
top: 50px;
|
||||
width: 100%;
|
||||
background-color: #fff;
|
||||
border: 1px solid #eee;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import React, { Component, PropTypes } from 'react';
|
||||
import { debounce } from 'lodash';
|
||||
import classNames from 'classnames';
|
||||
import * as propTypes from '../../util/propTypes';
|
||||
import { getPlacePredictions, getPlaceDetails } from '../../util/googleMaps';
|
||||
|
||||
|
|
@ -251,7 +252,8 @@ class LocationAutocompleteInput extends Component {
|
|||
});
|
||||
}
|
||||
render() {
|
||||
const { onFocus, onBlur } = this.props.input;
|
||||
const { className, placeholder, input } = this.props;
|
||||
const { name, onFocus, onBlur } = input;
|
||||
const { search, predictions } = currentValue(this.props);
|
||||
|
||||
const handleOnFocus = e => {
|
||||
|
|
@ -274,8 +276,10 @@ class LocationAutocompleteInput extends Component {
|
|||
return (
|
||||
<div className={css.root}>
|
||||
<input
|
||||
className={css.input}
|
||||
className={classNames(css.input, className)}
|
||||
type="search"
|
||||
placeholder={placeholder}
|
||||
name={name}
|
||||
value={search}
|
||||
onFocus={handleOnFocus}
|
||||
onBlur={handleOnBlur}
|
||||
|
|
@ -302,8 +306,13 @@ class LocationAutocompleteInput extends Component {
|
|||
}
|
||||
}
|
||||
|
||||
LocationAutocompleteInput.defaultProps = { className: '', placeholder: '' };
|
||||
|
||||
LocationAutocompleteInput.propTypes = {
|
||||
className: string,
|
||||
placeholder: string,
|
||||
input: shape({
|
||||
name: string.isRequired,
|
||||
value: shape({
|
||||
search: string,
|
||||
predictions: any,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
.locationInput {
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,21 +1,25 @@
|
|||
import React from 'react';
|
||||
import { Field, reduxForm, propTypes as formPropTypes } from 'redux-form';
|
||||
import { FormattedMessage, intlShape, injectIntl } from 'react-intl';
|
||||
import { LocationAutocompleteInput } from '../../components';
|
||||
import { autocompleteSearchRequired, autocompletePlaceSelected } from '../../util/validators';
|
||||
|
||||
import css from './HeroSearchForm.css';
|
||||
|
||||
const HeroSearchForm = props => {
|
||||
const { className, intl, handleSubmit, pristine, submitting } = props;
|
||||
const addClassName = className ? { className } : {};
|
||||
const placeholderMsg = { id: 'HeroSearchForm.placeholder' };
|
||||
|
||||
return (
|
||||
<form {...addClassName} onSubmit={handleSubmit}>
|
||||
<Field
|
||||
name="location"
|
||||
className={css.locationInput}
|
||||
component="input"
|
||||
type="text"
|
||||
placeholder={intl.formatMessage(placeholderMsg)}
|
||||
name="location"
|
||||
label="Location"
|
||||
placeholder={intl.formatMessage({ id: 'HeroSearchForm.placeholder' })}
|
||||
format={null}
|
||||
component={LocationAutocompleteInput}
|
||||
validate={[autocompleteSearchRequired(), autocompletePlaceSelected()]}
|
||||
/>
|
||||
<button className={css.locationButton} type="submit" disabled={pristine || submitting}>
|
||||
<FormattedMessage id="HeroSearchForm.search" />
|
||||
|
|
|
|||
|
|
@ -4,7 +4,9 @@ import HeroSearchForm from './HeroSearchForm';
|
|||
|
||||
describe('HeroSearchForm', () => {
|
||||
it('matches snapshot', () => {
|
||||
window.google = { maps: {} };
|
||||
const tree = renderDeep(<HeroSearchForm />);
|
||||
expect(tree).toMatchSnapshot();
|
||||
delete window.google;
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,17 +1,19 @@
|
|||
exports[`HeroSearchForm matches snapshot 1`] = `
|
||||
<form
|
||||
onSubmit={[Function]}>
|
||||
<input
|
||||
className={undefined}
|
||||
name="location"
|
||||
onBlur={[Function]}
|
||||
onChange={[Function]}
|
||||
onDragStart={[Function]}
|
||||
onDrop={[Function]}
|
||||
onFocus={[Function]}
|
||||
placeholder="Location search (soon)"
|
||||
type="text"
|
||||
value="" />
|
||||
<div
|
||||
className={undefined}>
|
||||
<input
|
||||
className=""
|
||||
name="location"
|
||||
onBlur={[Function]}
|
||||
onChange={[Function]}
|
||||
onFocus={[Function]}
|
||||
onKeyDown={[Function]}
|
||||
placeholder="Search by location, e.g. New York"
|
||||
type="search"
|
||||
value="" />
|
||||
</div>
|
||||
<button
|
||||
className={undefined}
|
||||
disabled={true}
|
||||
|
|
|
|||
|
|
@ -1,38 +1,40 @@
|
|||
import React, { PropTypes } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { HeroSection, NamedRedirect, PageLayout } from '../../components';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
import { HeroSection, PageLayout } from '../../components';
|
||||
import { HeroSearchForm } from '../../containers';
|
||||
import { changeLocationFilter } from '../../ducks/LocationFilter.duck';
|
||||
import { pathByRouteName } from '../../util/routes';
|
||||
import { stringify } from '../../util/urlHelpers';
|
||||
import * as propTypes from '../../util/propTypes';
|
||||
|
||||
import css from './LandingPage.css';
|
||||
|
||||
const createSubmitHandler = onLocationChanged =>
|
||||
formData => {
|
||||
onLocationChanged(formData.location);
|
||||
export const LandingPageComponent = props => {
|
||||
const { push: historyPush, flattenedRoutes } = props;
|
||||
|
||||
const handleSubmit = values => {
|
||||
const { location: { selectedPlace } } = values;
|
||||
const { address, origin, bounds } = selectedPlace;
|
||||
const searchQuery = stringify({ address, origin, bounds });
|
||||
const path = pathByRouteName('SearchPage', flattenedRoutes);
|
||||
historyPush(`${path}?${searchQuery}`);
|
||||
};
|
||||
|
||||
export const LandingPageComponent = props => {
|
||||
const { onLocationChanged, filter } = props;
|
||||
const handleSubmit = createSubmitHandler(onLocationChanged);
|
||||
|
||||
return filter.length > 0
|
||||
? <NamedRedirect name="SearchPage" search={`location=${filter}`} />
|
||||
: <PageLayout title="Landing page">
|
||||
<HeroSection>
|
||||
<HeroSearchForm className={css.form} onSubmit={handleSubmit} />
|
||||
</HeroSection>
|
||||
</PageLayout>;
|
||||
return (
|
||||
<PageLayout title="Landing page">
|
||||
<HeroSection>
|
||||
<HeroSearchForm className={css.form} onSubmit={handleSubmit} />
|
||||
</HeroSection>
|
||||
</PageLayout>
|
||||
);
|
||||
};
|
||||
|
||||
const { func, string } = PropTypes;
|
||||
const { func, arrayOf } = PropTypes;
|
||||
|
||||
LandingPageComponent.defaultProps = { filter: '' };
|
||||
LandingPageComponent.propTypes = {
|
||||
flattenedRoutes: arrayOf(propTypes.route).isRequired,
|
||||
|
||||
LandingPageComponent.propTypes = { onLocationChanged: func.isRequired, filter: string };
|
||||
|
||||
const mapStateToProps = state => ({ filter: state.LocationFilter });
|
||||
|
||||
const mapDispatchToProps = function mapDispatchToProps(dispatch) {
|
||||
return { onLocationChanged: v => dispatch(changeLocationFilter(v)) };
|
||||
// history.push from withRouter
|
||||
push: func.isRequired,
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(LandingPageComponent);
|
||||
export default withRouter(LandingPageComponent);
|
||||
|
|
|
|||
|
|
@ -4,9 +4,13 @@ import { LandingPageComponent } from './LandingPage';
|
|||
import { RoutesProvider } from '../../components';
|
||||
import routesConfiguration from '../../routesConfiguration';
|
||||
|
||||
const noop = () => null;
|
||||
|
||||
describe('LandingPage', () => {
|
||||
it('matches snapshot', () => {
|
||||
const tree = renderShallow(<LandingPageComponent onLocationChanged={v => v} />);
|
||||
const tree = renderShallow(
|
||||
<LandingPageComponent onLocationChanged={v => v} push={noop} flattenedRoutes={[]} />
|
||||
);
|
||||
expect(tree).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,141 +1,69 @@
|
|||
/**
|
||||
* This file contains Action constants, Action creators, and reducer of a page
|
||||
* container. We are following Ducks module proposition:
|
||||
* https://github.com/erikras/ducks-modular-redux
|
||||
*/
|
||||
import { unionWith, isEqual } from 'lodash';
|
||||
import { call, put, takeEvery } from 'redux-saga/effects';
|
||||
import { types } from '../../util/sdkLoader';
|
||||
import { createRequestTypes } from '../../util/sagaHelpers';
|
||||
|
||||
const { LatLng } = types;
|
||||
import { showListingsSuccess } from '../../ducks/sdk.duck';
|
||||
|
||||
// ================ Action types ================ //
|
||||
|
||||
export const ADD_FILTER = 'app/SearchPage/ADD_FILTER';
|
||||
|
||||
// async actions use request - success / failure pattern
|
||||
export const LOAD_LISTINGS = createRequestTypes('app/SearchPage/LOAD_LISTINGS');
|
||||
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';
|
||||
|
||||
// ================ Reducer ================ //
|
||||
|
||||
export const initialState = {
|
||||
filters: [],
|
||||
initialListingsLoaded: false,
|
||||
listings: [],
|
||||
loadingListings: false,
|
||||
const initialState = {
|
||||
searchParams: null,
|
||||
searchListingsError: null,
|
||||
currentPageResultIds: [],
|
||||
};
|
||||
|
||||
export default function reducer(state = initialState, action = {}) {
|
||||
const { type, payload } = action;
|
||||
const resultIds = data => data.data.map(l => l.id);
|
||||
|
||||
const listingPageReducer = (state = initialState, action = {}) => {
|
||||
const { type, payload } = action;
|
||||
switch (type) {
|
||||
case ADD_FILTER: {
|
||||
const stateFilters = state.filters || [];
|
||||
return { ...state, ...{ filters: unionWith(stateFilters, [payload], isEqual) } };
|
||||
}
|
||||
case LOAD_LISTINGS.REQUEST:
|
||||
return { ...state, loadingListings: true, initialListingsLoaded: false };
|
||||
case LOAD_LISTINGS.SUCCESS:
|
||||
return { ...state, loadingListings: false, initialListingsLoaded: true, listings: payload };
|
||||
case LOAD_LISTINGS.FAILURE:
|
||||
return { ...state, loadingListings: false, error: true, loadListingError: payload };
|
||||
case SEARCH_LISTINGS_REQUEST:
|
||||
return { ...state, searchParams: payload.searchParams, searchListingsError: null };
|
||||
case SEARCH_LISTINGS_SUCCESS:
|
||||
return { ...state, currentPageResultIds: resultIds(payload.data) };
|
||||
case SEARCH_LISTINGS_ERROR:
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(payload);
|
||||
return { ...state, searchListingsError: payload };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default listingPageReducer;
|
||||
|
||||
// ================ Action creators ================ //
|
||||
|
||||
export const addFilter = (key, value) => ({ type: ADD_FILTER, payload: { [key]: value } });
|
||||
export const searchListingsRequest = searchParams => ({
|
||||
type: SEARCH_LISTINGS_REQUEST,
|
||||
payload: { searchParams },
|
||||
});
|
||||
|
||||
// async actions use request - success / failure pattern
|
||||
export const loadListings = {
|
||||
request: () => ({ type: LOAD_LISTINGS.REQUEST, payload: {} }),
|
||||
success: listings => ({ type: LOAD_LISTINGS.SUCCESS, payload: listings }),
|
||||
failure: error => ({ type: LOAD_LISTINGS.FAILURE, payload: error }),
|
||||
};
|
||||
export const searchListingsSuccess = response => ({
|
||||
type: SEARCH_LISTINGS_SUCCESS,
|
||||
payload: { data: response.data },
|
||||
});
|
||||
|
||||
// ================ Worker sagas ================ //
|
||||
export const searchListingsError = e => ({
|
||||
type: SEARCH_LISTINGS_ERROR,
|
||||
error: true,
|
||||
payload: e,
|
||||
});
|
||||
|
||||
/**
|
||||
Take `included` values from the JSON API response
|
||||
and transforms it to a map which provide easy and fast lookup.
|
||||
Example:
|
||||
```
|
||||
const included = [
|
||||
{ id: new UUID(123), type: 'user', attributes: { name: "John" }},
|
||||
{ id: new UUID(234), type: 'user', attributes: { name: "Jane" }},
|
||||
];
|
||||
const map = lookupMap(included)
|
||||
#=> returns
|
||||
# {
|
||||
# user: {
|
||||
# 123: { id: 123, type: 'user', attributes: { name: "John" }},
|
||||
# 234: { id: 234, type: 'user', attributes: { name: "Jane" }},
|
||||
# }
|
||||
# }
|
||||
map.user.123
|
||||
#=> returns
|
||||
# { id: 123, type: 'user', attributes: { name: "John" }},
|
||||
```
|
||||
*/
|
||||
const lookupMap = included =>
|
||||
included.reduce(
|
||||
(memo, resource) => {
|
||||
const { type, id } = resource;
|
||||
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
memo[type] = memo[type] || {};
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
memo[type][id.uuid] = resource;
|
||||
|
||||
return memo;
|
||||
},
|
||||
{}
|
||||
);
|
||||
|
||||
// Format the data as ListingCard component expects it and
|
||||
// add some fake data
|
||||
// TODO Remove this and think about the real implementation
|
||||
const formatListingData = response => {
|
||||
const includedMap = lookupMap(response.data.included);
|
||||
const data = response.data.data;
|
||||
|
||||
return data.map(({ id, attributes, relationships }) => {
|
||||
const author = includedMap.user[relationships.author.data.id.uuid];
|
||||
const { firstName, lastName } = author.attributes.profile;
|
||||
|
||||
return {
|
||||
id: id.uuid,
|
||||
title: attributes.title,
|
||||
description: attributes.description,
|
||||
location: attributes.address,
|
||||
price: '55\u20AC / day',
|
||||
author: {
|
||||
name: `${firstName} ${lastName}`,
|
||||
avatar: 'http://placehold.it/44x44',
|
||||
review: { rating: '4' },
|
||||
},
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
export function* callFetchListings(sdk) {
|
||||
try {
|
||||
const response = yield call(sdk.listings.search, {
|
||||
origin: new LatLng(40, 70),
|
||||
include: ['author'],
|
||||
});
|
||||
yield put(loadListings.success(formatListingData(response)));
|
||||
} catch (error) {
|
||||
yield put(loadListings.failure(error));
|
||||
}
|
||||
}
|
||||
|
||||
// ================ Watcher sagas ================ //
|
||||
|
||||
export function* watchLoadListings(sdk) {
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
yield takeEvery(LOAD_LISTINGS.REQUEST, callFetchListings, sdk);
|
||||
}
|
||||
export const searchListings = searchParams =>
|
||||
(dispatch, getState, sdk) => {
|
||||
dispatch(searchListingsRequest(searchParams));
|
||||
return sdk.listings
|
||||
.search(searchParams)
|
||||
.then(response => {
|
||||
dispatch(searchListingsSuccess(response));
|
||||
dispatch(showListingsSuccess(response));
|
||||
return response;
|
||||
})
|
||||
.catch(e => {
|
||||
dispatch(searchListingsError(e));
|
||||
throw e;
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import React, { Component, PropTypes } from 'react';
|
||||
import React, { PropTypes } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { intlShape, injectIntl } from 'react-intl';
|
||||
import { connect } from 'react-redux';
|
||||
import { types } from '../../util/sdkLoader';
|
||||
import { searchListings, getListingsById } from '../../ducks/sdk.duck';
|
||||
import { parse } from '../../util/urlHelpers';
|
||||
import { getListingsById } from '../../ducks/sdk.duck';
|
||||
import {
|
||||
FilterPanel,
|
||||
ListingCard,
|
||||
|
|
@ -11,62 +12,92 @@ import {
|
|||
PageLayout,
|
||||
SearchResultsPanel,
|
||||
} from '../../components';
|
||||
import { searchListings } from './SearchPage.duck';
|
||||
|
||||
import css from './SearchPage.css';
|
||||
|
||||
const { LatLng } = types;
|
||||
export const SearchPageComponent = props => {
|
||||
const { tab, listings, searchParams, searchListingsError, intl } = props;
|
||||
|
||||
export class SearchPageComponent extends Component {
|
||||
componentDidMount() {
|
||||
SearchPageComponent.loadData(this.props.dispatch);
|
||||
}
|
||||
const filtersClassName = classNames(css.filters, { [css.open]: tab === 'filters' });
|
||||
const listingsClassName = classNames(css.listings, { [css.open]: tab === 'listings' });
|
||||
const mapClassName = classNames(css.map, { [css.open]: tab === 'map' });
|
||||
|
||||
render() {
|
||||
const { tab, listings } = this.props;
|
||||
const searchWasDone = searchParams && searchParams.address;
|
||||
|
||||
const filtersClassName = classNames(css.filters, { [css.open]: tab === 'filters' });
|
||||
const listingsClassName = classNames(css.listings, { [css.open]: tab === 'listings' });
|
||||
const mapClassName = classNames(css.map, { [css.open]: tab === 'map' });
|
||||
const searchErrorMessage = intl.formatMessage({ id: 'SearchPage.searchError' });
|
||||
const resultsFoundMessage = intl.formatMessage(
|
||||
{ id: 'SearchPage.foundResults' },
|
||||
{
|
||||
count: listings.length,
|
||||
address: searchParams && searchParams.address ? searchParams.address : '',
|
||||
}
|
||||
);
|
||||
const noResultsMessage = intl.formatMessage(
|
||||
{ id: 'SearchPage.noResults' },
|
||||
{
|
||||
address: searchParams && searchParams.address ? searchParams.address : '',
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<PageLayout title="Search page">
|
||||
<div className={css.container}>
|
||||
<div className={filtersClassName}>
|
||||
<FilterPanel />
|
||||
</div>
|
||||
<div className={listingsClassName}>
|
||||
<SearchResultsPanel>
|
||||
{listings.map(l => <ListingCard key={l.id.uuid} listing={l} />)}
|
||||
</SearchResultsPanel>
|
||||
</div>
|
||||
<div className={mapClassName}>
|
||||
<MapPanel>
|
||||
{listings.map(l => <ListingCardSmall key={l.id.uuid} listing={l} />)}
|
||||
</MapPanel>
|
||||
</div>
|
||||
return (
|
||||
<PageLayout title="Search page">
|
||||
{searchListingsError ? <p style={{ color: 'red' }}>{searchErrorMessage}</p> : null}
|
||||
{searchWasDone && listings.length > 0 ? <p>{resultsFoundMessage}</p> : null}
|
||||
{searchWasDone && listings.length === 0 ? <p>{noResultsMessage}</p> : null}
|
||||
<div className={css.container}>
|
||||
<div className={filtersClassName}>
|
||||
<FilterPanel />
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
SearchPageComponent.loadData = dispatch => {
|
||||
dispatch(searchListings({ origin: new LatLng(40, 70), include: ['author', 'images'] }));
|
||||
<div className={listingsClassName}>
|
||||
<SearchResultsPanel>
|
||||
{listings.map(l => <ListingCard key={l.id.uuid} listing={l} />)}
|
||||
</SearchResultsPanel>
|
||||
</div>
|
||||
<div className={mapClassName}>
|
||||
<MapPanel>
|
||||
{listings.map(l => <ListingCardSmall key={l.id.uuid} listing={l} />)}
|
||||
</MapPanel>
|
||||
</div>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
};
|
||||
|
||||
SearchPageComponent.defaultProps = { initialListingsLoaded: false, listings: [], tab: 'listings' };
|
||||
SearchPageComponent.defaultProps = {
|
||||
tab: 'listings',
|
||||
listings: [],
|
||||
searchParams: {},
|
||||
searchListingsError: null,
|
||||
};
|
||||
|
||||
const { array, func, oneOf } = PropTypes;
|
||||
const { array, oneOf, object, instanceOf } = PropTypes;
|
||||
|
||||
SearchPageComponent.propTypes = {
|
||||
listings: array,
|
||||
tab: oneOf(['filters', 'listings', 'map']).isRequired,
|
||||
dispatch: func.isRequired,
|
||||
listings: array,
|
||||
searchParams: object,
|
||||
searchListingsError: instanceOf(Error),
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
const mapStateToProps = state => {
|
||||
const listingIds = state.data.searchPageResults;
|
||||
return { listings: getListingsById(state.data, listingIds) };
|
||||
const { searchParams, searchListingsError, currentPageResultIds } = state.SearchPage;
|
||||
return {
|
||||
listings: getListingsById(state.data, currentPageResultIds),
|
||||
searchParams,
|
||||
searchListingsError,
|
||||
};
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps)(SearchPageComponent);
|
||||
const SearchPage = connect(mapStateToProps)(injectIntl(SearchPageComponent));
|
||||
|
||||
SearchPage.loadData = (params, search) => {
|
||||
const queryParams = parse(search, {
|
||||
latlng: ['origin'],
|
||||
latlngBounds: ['bounds'],
|
||||
});
|
||||
return searchListings({ ...queryParams, include: ['author', 'images'] });
|
||||
};
|
||||
|
||||
export default SearchPage;
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import React from 'react';
|
|||
import { renderShallow } from '../../util/test-helpers';
|
||||
import { call, put, fork, takeEvery } from 'redux-saga/effects';
|
||||
import { types } from '../../util/sdkLoader';
|
||||
import { fakeIntl } from '../../util/test-helpers';
|
||||
import { SearchPageComponent } from './SearchPage';
|
||||
import reducer, {
|
||||
ADD_FILTER,
|
||||
|
|
@ -18,85 +19,8 @@ const { LatLng } = types;
|
|||
describe('SearchPageComponent', () => {
|
||||
it('matches snapshot', () => {
|
||||
const tree = renderShallow(
|
||||
<SearchPageComponent onLoadListings={v => v} dispatch={() => null} />
|
||||
<SearchPageComponent onLoadListings={v => v} dispatch={() => null} intl={fakeIntl} />
|
||||
);
|
||||
expect(tree).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
describe('SearchPageDucs', () => {
|
||||
describe('actions', () => {
|
||||
it('should create an action to add a filter', () => {
|
||||
const expectedAction = { type: ADD_FILTER, payload: { location: 'helsinki' } };
|
||||
expect(addFilter('location', 'helsinki')).toEqual(expectedAction);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reducer', () => {
|
||||
it('should return the initial state', () => {
|
||||
const initial = reducer(undefined, {});
|
||||
expect(initial).toEqual(initialState);
|
||||
});
|
||||
|
||||
it('should handle ADD_FILTER', () => {
|
||||
const addFilter1 = addFilter('location', 'helsinki');
|
||||
const addFilter2 = addFilter('gears', 3);
|
||||
const reduced = reducer([], addFilter1);
|
||||
const reducedWithInitialContent = reducer({ filters: [addFilter1.payload] }, addFilter2);
|
||||
expect(reduced).toEqual({ filters: [addFilter1.payload] });
|
||||
expect(reducedWithInitialContent).toEqual({
|
||||
filters: [addFilter1.payload, addFilter2.payload],
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle duplicates ADD_FILTER', () => {
|
||||
const filter = { location: 'helsinki' };
|
||||
const addFilter = { type: ADD_FILTER, payload: filter };
|
||||
const reducedWithInitialContent = reducer({ filters: [filter] }, addFilter);
|
||||
expect(reducedWithInitialContent).toEqual({ filters: [filter] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('callFetchListings worker', () => {
|
||||
it('should succeed when API call fulfills', () => {
|
||||
const payload = { data: { data: [], included: [] } };
|
||||
const sdk = { listings: { search: jest.fn() } };
|
||||
const worker = callFetchListings(sdk);
|
||||
|
||||
expect(worker.next()).toEqual({
|
||||
value: call(sdk.listings.search, { origin: new LatLng(40, 70), include: ['author'] }),
|
||||
done: false,
|
||||
});
|
||||
expect(worker.next(payload)).toEqual({ value: put(loadListings.success([])), done: false });
|
||||
expect(worker.next().done).toEqual(true);
|
||||
expect(sdk.listings.search).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should fail when API call rejects', () => {
|
||||
const payload = {};
|
||||
const sdk = { listings: { search: jest.fn() } };
|
||||
const worker = callFetchListings(sdk);
|
||||
const error = new Error('Test listing fetch failed');
|
||||
|
||||
expect(worker.next()).toEqual({
|
||||
value: call(sdk.listings.search, { origin: new LatLng(40, 70), include: ['author'] }),
|
||||
done: false,
|
||||
});
|
||||
expect(worker.throw(error)).toEqual({ value: put(loadListings.failure(error)), done: false });
|
||||
expect(worker.next().done).toEqual(true);
|
||||
expect(sdk.listings.search).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('load listings watcher', () => {
|
||||
it('creates takeEvery helper for listening LOAD_LISTINGS.REQUEST actions', () => {
|
||||
const sdk = { fetchListings: jest.fn() };
|
||||
const watcher = watchLoadListings(sdk);
|
||||
const takeLoadListingsRequest = takeEvery(LOAD_LISTINGS.REQUEST, callFetchListings, sdk);
|
||||
|
||||
// The watcher should use takeEvery (a wrapper for forking saga's internal takeEveryHelper)
|
||||
expect(watcher.next().value).toEqual(takeLoadListingsRequest);
|
||||
expect(sdk.fetchListings).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -30,24 +30,28 @@ const routesConfiguration = [
|
|||
exact: true,
|
||||
name: 'SearchPage',
|
||||
component: props => <SearchPage {...props} />,
|
||||
loadData: (params, search) => SearchPage.loadData(params, search),
|
||||
routes: [
|
||||
{
|
||||
path: '/s/filters',
|
||||
exact: true,
|
||||
name: 'SearchFiltersPage',
|
||||
component: props => <SearchPage {...props} tab="filters" />,
|
||||
loadData: (params, search) => SearchPage.loadData(params, search),
|
||||
},
|
||||
{
|
||||
path: '/s/listings',
|
||||
exact: true,
|
||||
name: 'SearchListingsPage',
|
||||
component: props => <SearchPage {...props} tab="listings" />,
|
||||
loadData: (params, search) => SearchPage.loadData(params, search),
|
||||
},
|
||||
{
|
||||
path: '/s/map',
|
||||
exact: true,
|
||||
name: 'SearchMapPage',
|
||||
component: props => <SearchPage {...props} tab="map" />,
|
||||
loadData: (params, search) => SearchPage.loadData(params, search),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
import { watchAuthInfo, watchAuth } from './ducks/Auth.duck';
|
||||
import { watchLoadListings } from './containers/SearchPage/SearchPage.duck';
|
||||
import { watchSdk } from './ducks/sdk.duck';
|
||||
|
||||
const createRootSaga = sdk =>
|
||||
function* rootSaga() {
|
||||
yield [watchAuthInfo(sdk), watchAuth(sdk), watchLoadListings(sdk), watchSdk(sdk)];
|
||||
yield [watchAuthInfo(sdk), watchAuth(sdk), watchSdk(sdk)];
|
||||
};
|
||||
|
||||
export default createRootSaga;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"AuthenticationPage.loginFailed": "The email and password you entered did not match our records. Please double-check and try again.",
|
||||
"AuthenticationPage.loginRequiredFor": "You must log in to view the page at",
|
||||
"HeroSearchForm.placeholder": "Location search (soon)",
|
||||
"HeroSearchForm.placeholder": "Search by location, e.g. New York",
|
||||
"HeroSearchForm.search": "Search",
|
||||
"HeroSection.subTitle": "The largest online community to rent music studios",
|
||||
"HeroSection.title": "Book Studiotime anywhere",
|
||||
|
|
@ -14,5 +14,8 @@
|
|||
"ListingPage.loadingListingData": "Loading listing data",
|
||||
"ListingPage.noListingData": "Could not find listing data",
|
||||
"PageLayout.authInfoFailed": "Could not get authentication information.",
|
||||
"PageLayout.logoutFailed": "Logout failed. Please try again."
|
||||
"PageLayout.logoutFailed": "Logout failed. Please try again.",
|
||||
"SearchPage.searchError": "Search failed. Please try again.",
|
||||
"SearchPage.foundResults": "{count} listings found near {address}.",
|
||||
"SearchPage.noResults": "Could not find any listings near {address}."
|
||||
}
|
||||
|
|
|
|||
166
src/util/urlHelpers.js
Normal file
166
src/util/urlHelpers.js
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
import queryString from 'query-string';
|
||||
import { types } from './sdkLoader';
|
||||
|
||||
const { LatLng, LatLngBounds } = types;
|
||||
|
||||
export const createSlug = str => encodeURIComponent(str.toLowerCase().split(' ').join('-'));
|
||||
|
||||
/**
|
||||
* Parse float from a string
|
||||
*
|
||||
* @param {String} str - string to parse
|
||||
*
|
||||
* @return {Number|null} number parsed from the string, null if not a number
|
||||
*/
|
||||
export const parseFloatNum = str => {
|
||||
const num = parseFloat(str);
|
||||
return isNaN(num) ? null : num;
|
||||
};
|
||||
|
||||
/**
|
||||
* Encode a location to use in a URL
|
||||
*
|
||||
* @param {LatLng} location - location instance to encode
|
||||
*
|
||||
* @return {String} location coordinates separated by a comma
|
||||
*/
|
||||
export const encodeLatLng = location => `${location.lat},${location.lng}`;
|
||||
|
||||
/**
|
||||
* Decode a location from a string
|
||||
*
|
||||
* @param {String} str - string encoded with `encodeLatLng`
|
||||
*
|
||||
* @return {LatLng|null} location instance, null if could not parse
|
||||
*/
|
||||
export const decodeLatLng = str => {
|
||||
const parts = str.split(',');
|
||||
if (parts.length !== 2) {
|
||||
return null;
|
||||
}
|
||||
const lat = parseFloatNum(parts[0]);
|
||||
const lng = parseFloatNum(parts[1]);
|
||||
if (lat === null || lng === null) {
|
||||
return null;
|
||||
}
|
||||
return new LatLng(lat, lng);
|
||||
};
|
||||
|
||||
/**
|
||||
* Encode a location bounds to use in a URL
|
||||
*
|
||||
* @param {LatLngBounds} bounds - bounds instance to encode
|
||||
*
|
||||
* @return {String} bounds coordinates separated by a comma
|
||||
*/
|
||||
export const encodeLatLngBounds = bounds => `${encodeLatLng(bounds.ne)},${encodeLatLng(bounds.sw)}`;
|
||||
|
||||
/**
|
||||
* Decode a location bounds from a string
|
||||
*
|
||||
* @param {String} str - string encoded with `encodeLatLngBounds`
|
||||
*
|
||||
* @return {LatLngBounds|null} location bounds instance, null if could not parse
|
||||
*/
|
||||
export const decodeLatLngBounds = str => {
|
||||
const parts = str.split(',');
|
||||
if (parts.length !== 4) {
|
||||
return null;
|
||||
}
|
||||
const ne = decodeLatLng(`${parts[0]},${parts[1]}`);
|
||||
const sw = decodeLatLng(`${parts[2]},${parts[3]}`);
|
||||
if (ne === null || sw === null) {
|
||||
return null;
|
||||
}
|
||||
return new LatLngBounds(ne, sw);
|
||||
};
|
||||
|
||||
// Serialise SDK types in given object values into strings
|
||||
const serialiseSdkTypes = obj =>
|
||||
Object.keys(obj).reduce(
|
||||
(result, key) => {
|
||||
const val = obj[key];
|
||||
/* eslint-disable no-param-reassign */
|
||||
if (val instanceof LatLngBounds) {
|
||||
result[key] = encodeLatLngBounds(val);
|
||||
} else if (val instanceof LatLng) {
|
||||
result[key] = encodeLatLng(val);
|
||||
} else {
|
||||
result[key] = val;
|
||||
}
|
||||
/* eslint-enable no-param-reassign */
|
||||
return result;
|
||||
},
|
||||
{}
|
||||
);
|
||||
|
||||
/**
|
||||
* Serialise given object into a string that can be used in a
|
||||
* URL. Encode SDK types into a format that can be parsed with `parse`
|
||||
* defined below.
|
||||
*
|
||||
* @param {Object} params - object with strings/numbers/booleans or
|
||||
* SDK types as values
|
||||
*
|
||||
* @return {String} query string with sorted keys and serialised
|
||||
* values, `undefined` and `null` values are removed
|
||||
*/
|
||||
export const stringify = params => {
|
||||
const serialised = serialiseSdkTypes(params);
|
||||
const cleaned = Object.keys(serialised).reduce(
|
||||
(result, key) => {
|
||||
const val = serialised[key];
|
||||
/* eslint-disable no-param-reassign */
|
||||
if (val !== null) {
|
||||
result[key] = val;
|
||||
}
|
||||
/* eslint-enable no-param-reassign */
|
||||
return result;
|
||||
},
|
||||
{}
|
||||
);
|
||||
return queryString.stringify(cleaned);
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse a URL search query. Converts numeric values into numbers,
|
||||
* 'true' and 'false' as booleans, and serialised LatLng and
|
||||
* LatLngBounds into respective instances based on given options.
|
||||
*
|
||||
* @param {String} search - query string to parse, optionally with a
|
||||
* leading '?' or '#' character
|
||||
*
|
||||
* @param {Object} options - Options for parsing:
|
||||
*
|
||||
* - latlng {Array<String} keys to parse as LatLng instances, null if
|
||||
* not able to parse
|
||||
* - latlngBounds {Array<String} keys to parse as LatLngBounds
|
||||
* instances, null if not able to parse
|
||||
*
|
||||
* @return {Object} key/value pairs parsed from the given String
|
||||
*/
|
||||
export const parse = (search, options = {}) => {
|
||||
const { latlng = [], latlngBounds = [] } = options;
|
||||
const params = queryString.parse(search);
|
||||
return Object.keys(params).reduce(
|
||||
(result, key) => {
|
||||
const val = params[key];
|
||||
/* eslint-disable no-param-reassign */
|
||||
if (latlng.includes(key)) {
|
||||
result[key] = decodeLatLng(val);
|
||||
} else if (latlngBounds.includes(key)) {
|
||||
result[key] = decodeLatLngBounds(val);
|
||||
} else if (val === 'true') {
|
||||
result[key] = true;
|
||||
} else if (val === 'false') {
|
||||
result[key] = false;
|
||||
} else {
|
||||
const num = parseFloatNum(val);
|
||||
result[key] = num === null ? val : num;
|
||||
}
|
||||
/* eslint-enable no-param-reassign */
|
||||
return result;
|
||||
},
|
||||
{}
|
||||
);
|
||||
};
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
/* eslint-disable import/prefer-default-export */
|
||||
|
||||
export const createSlug = (str) =>
|
||||
encodeURIComponent(str.toLowerCase().split(' ').join('-'))
|
||||
122
src/util/urlHelpers.test.js
Normal file
122
src/util/urlHelpers.test.js
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import { types } from './sdkLoader';
|
||||
import {
|
||||
parseFloatNum,
|
||||
encodeLatLng,
|
||||
decodeLatLng,
|
||||
encodeLatLngBounds,
|
||||
decodeLatLngBounds,
|
||||
stringify,
|
||||
parse,
|
||||
} from './urlHelpers';
|
||||
|
||||
const { LatLng, LatLngBounds } = types;
|
||||
|
||||
const SPACE = encodeURIComponent(' ');
|
||||
const COMMA = encodeURIComponent(',');
|
||||
|
||||
describe('urlHelpers', () => {
|
||||
describe('parseFloatNum()', () => {
|
||||
it('handles empty value', () => {
|
||||
expect(parseFloatNum('')).toBeNull();
|
||||
});
|
||||
|
||||
it('handles non-numeric value', () => {
|
||||
expect(parseFloatNum('abc')).toBeNull();
|
||||
});
|
||||
|
||||
it('handles int value with surrounding whitespace', () => {
|
||||
expect(parseFloatNum(' 123 \t')).toEqual(123);
|
||||
});
|
||||
|
||||
it('handles float value', () => {
|
||||
expect(parseFloatNum('123.01')).toBeCloseTo(123.01, 2);
|
||||
});
|
||||
|
||||
it('handles trailing chars', () => {
|
||||
expect(parseFloatNum('123abc')).toEqual(123);
|
||||
});
|
||||
});
|
||||
|
||||
describe('LatLng serialisation', () => {
|
||||
it('encodes and decodes', () => {
|
||||
const location = new LatLng(40, 60);
|
||||
expect(decodeLatLng(encodeLatLng(location))).toEqual(location);
|
||||
});
|
||||
});
|
||||
|
||||
describe('LatLngBounds serialisation', () => {
|
||||
it('encodes and decodes', () => {
|
||||
const bounds = new LatLngBounds(new LatLng(50, 70), new LatLng(30, 50));
|
||||
expect(decodeLatLngBounds(encodeLatLngBounds(bounds))).toEqual(bounds);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stringify()', () => {
|
||||
it('handles empty params', () => {
|
||||
expect(stringify({})).toEqual('');
|
||||
});
|
||||
|
||||
it('sorts params', () => {
|
||||
const params = { b: 'B', c: 'C', a: 'A' };
|
||||
expect(stringify(params)).toEqual('a=A&b=B&c=C');
|
||||
});
|
||||
|
||||
it('encodes values', () => {
|
||||
const params = {
|
||||
space: 'A and b',
|
||||
num: 123,
|
||||
bool: true,
|
||||
undef: undefined,
|
||||
nil: null,
|
||||
};
|
||||
expect(stringify(params)).toEqual(`bool=true&num=123&space=A${SPACE}and${SPACE}b`);
|
||||
});
|
||||
|
||||
it('encodes SDK types', () => {
|
||||
const params = {
|
||||
origin: new LatLng(40, 60),
|
||||
bounds: new LatLngBounds(new LatLng(50, 70), new LatLng(30, 50)),
|
||||
};
|
||||
const origin = `40${COMMA}60`;
|
||||
const bounds = `50${COMMA}70${COMMA}30${COMMA}50`;
|
||||
expect(stringify(params)).toEqual(`bounds=${bounds}&origin=${origin}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parse()', () => {
|
||||
it('handles empty string', () => {
|
||||
expect(parse('')).toEqual({});
|
||||
});
|
||||
|
||||
it('handles question mark', () => {
|
||||
expect(parse('?')).toEqual({});
|
||||
});
|
||||
|
||||
it('decodes values', () => {
|
||||
const search = `bool1=true&bool2=false&num1=123&num2=-1.01&space=A${SPACE}and${SPACE}b`;
|
||||
expect(parse(search)).toEqual({
|
||||
space: 'A and b',
|
||||
num1: 123,
|
||||
num2: -1.01,
|
||||
bool2: false,
|
||||
bool1: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('decodes SDK types', () => {
|
||||
const origin = `40${COMMA}60`;
|
||||
const bounds = `50${COMMA}70${COMMA}30${COMMA}50`;
|
||||
const search = `bounds=${bounds}&origin=${origin}&invalid=a,10&badBounds=true`;
|
||||
const options = {
|
||||
latlng: ['origin', 'invalid'],
|
||||
latlngBounds: ['bounds', 'badBounds'],
|
||||
};
|
||||
expect(parse(search, options)).toEqual({
|
||||
origin: new LatLng(40, 60),
|
||||
invalid: null,
|
||||
bounds: new LatLngBounds(new LatLng(50, 70), new LatLng(30, 50)),
|
||||
badBounds: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -5299,7 +5299,7 @@ qs@~6.3.0:
|
|||
version "6.3.1"
|
||||
resolved "https://registry.yarnpkg.com/qs/-/qs-6.3.1.tgz#918c0b3bcd36679772baf135b1acb4c1651ed79d"
|
||||
|
||||
query-string@^4.1.0:
|
||||
query-string@^4.1.0, query-string@^4.3.2:
|
||||
version "4.3.2"
|
||||
resolved "https://registry.yarnpkg.com/query-string/-/query-string-4.3.2.tgz#ec0fd765f58a50031a3968c2431386f8947a5cdd"
|
||||
dependencies:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue