Merge pull request #77 from sharetribe/landing-page-form

Proper location search from landing page
This commit is contained in:
Kimmo Puputti 2017-03-23 11:26:20 +02:00 committed by GitHub
commit 2dbe2a7ed1
22 changed files with 512 additions and 307 deletions

View file

@ -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",

View file

@ -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);

View file

@ -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}

View file

@ -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', () => {

View file

@ -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;

View file

@ -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,

View file

@ -1,6 +1,5 @@
.locationInput {
width: 100%;
height: 50px;
background-color: white;
}

View file

@ -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" />

View file

@ -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;
});
});

View file

@ -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}

View file

@ -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);

View file

@ -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();
});
});

View file

@ -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;
});
};

View file

@ -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;

View file

@ -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();
});
});
});

View file

@ -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),
},
],
},

View file

@ -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;

View file

@ -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
View 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;
},
{}
);
};

View file

@ -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
View 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,
});
});
});
});

View file

@ -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: