Merge pull request #35 from sharetribe/fake-load-data

Fake load data
This commit is contained in:
Vesa Luusua 2017-02-07 14:16:58 +02:00 committed by GitHub
commit 0e71c2d41a
14 changed files with 346 additions and 351 deletions

View file

@ -23,7 +23,8 @@
"redux-saga": "^0.14.3",
"sanitize.css": "^4.1.0",
"sharetribe-scripts": "0.8.6",
"source-map-support": "^0.4.11"
"source-map-support": "^0.4.11",
"url": "^0.11.0"
},
"devDependencies": {
"nodemon": "^1.11.0",

View file

@ -5,7 +5,7 @@
<!--!title-->
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico">
<script><!--!preloadedStateScript--></script>
<!--!preloadedStateScript-->
</head>
<body>
<div id="root"><!--!body--></div>

35
server/fakeSDK.js Normal file
View file

@ -0,0 +1,35 @@
/* eslint-disable import/prefer-default-export, no-unused-vars */
const fakeListings = [
{
id: 123,
title: 'Banyan Studios',
price: '55\u20AC / day',
description: 'Organic Music Production in a Sustainable, Ethical and Professional Studio.',
location: 'New York, NY \u2022 40mi away',
review: { count: '8 reviews', rating: '4' },
author: {
name: 'The Stardust Collective',
avatar: 'http://placehold.it/44x44',
review: { rating: '4' },
},
},
{
id: 1234,
title: 'Pienix Studio',
price: '80\u20AC / day',
description: 'Pienix Studio specializes in music mixing and mastering production.',
location: 'New York, NY \u2022 6mi away',
review: { count: '7 reviews', rating: '4' },
author: { name: 'Juhan', avatar: 'http://placehold.it/44x44', review: { rating: '4' } },
},
];
/* eslint-enable import/prefer-default-export */
exports.fetchListings = () => {
const randomTime = Math.random() * 2000;
const fakeResponseTime = 1000 + randomTime;
return new Promise((resolve, reject) => {
setTimeout(() => resolve(fakeListings), fakeResponseTime);
});
};

View file

@ -22,10 +22,13 @@ const compression = require('compression');
const path = require('path');
const fs = require('fs');
const qs = require('qs');
const url = require('url');
const _ = require('lodash');
const React = require('react');
const { createServerRenderContext } = require('react-router');
const sagaEffects = require('redux-saga/effects');
const auth = require('./auth');
const sdk = require('./fakeSDK');
// Construct the bundle path where the server side rendering function
// can be imported.
@ -33,7 +36,10 @@ const buildPath = path.resolve(__dirname, '..', 'build');
const manifestPath = path.join(buildPath, 'asset-manifest.json');
const manifest = require(manifestPath);
const mainJsPath = path.join(buildPath, manifest['main.js']);
const renderApp = require(mainJsPath).default;
const mainJs = require(mainJsPath);
const renderApp = mainJs.default;
const matchPathname = mainJs.matchPathname;
const configureStore = mainJs.configureStore;
// The HTML build file is generated from the `public/index.html` file
// and used as a template for server side rendering. The application
@ -66,15 +72,52 @@ const template = _.template(indexHtml, {
escape: reNoMatch,
});
function render(url, context, preloadedState) {
const { head, body } = renderApp(url, context, preloadedState);
function fetchInitialState(requestUrl) {
const pathname = url.parse(requestUrl).pathname;
const { matchedRoutes, params } = matchPathname(pathname);
// pathname may match with several routes (if they don't have exactly=true)
// We filter all the components form matched routes that have `loadData`
const initialFetches = _
.chain(matchedRoutes)
.filter(r => r.loadData)
.map(r => sagaEffects.fork(r.loadData, sdk))
.value();
// We need to combine different onload sagas under one yield
const fetchInitialData = function* fetchInitialData() {
yield initialFetches;
};
// runSaga (if necessary) and return initial store state after loadData fetches.
if (initialFetches.length > 0) {
const fetchPromise = new Promise((resolve, reject) => {
const store = configureStore({});
store.runSaga(fetchInitialData).done
.then(() => {
resolve(store.getState());
})
.catch(e => {
reject(e);
});
// Close temporary store's saga middleware (which was used only for fetching initial store state)
store.closeSagaMiddleware();
});
return fetchPromise;
}
return Promise.resolve({});
}
function render(requestUrl, context, preloadedState) {
const { head, body } = renderApp(requestUrl, context, preloadedState);
// Preloaded state needs to be passed for client side too.
// For security reasons we ensure that preloaded state is considered as a string
// by replacing '<' character with its unicode equivalent.
// http://redux.js.org/docs/recipes/ServerRendering.html#security-considerations
const serializedState = JSON.stringify(preloadedState).replace(/</g, '\\u003c');
const preloadedStateScript = `
window.__PRELOADED_STATE__ = ${JSON.stringify(preloadedState).replace(/</g, '\\u003c')};
<script>window.__PRELOADED_STATE__ = ${serializedState};</script>
`;
return template({ title: head.title.toString(), preloadedStateScript, body });
@ -104,21 +147,26 @@ app.get('*', (req, res) => {
const filters = qs.parse(req.query);
// TODO fetch this asynchronously
const preloadedState = { search: { filters } };
fetchInitialState(req.url)
.then(preloadedState => {
const html = render(req.url, context, preloadedState);
const result = context.getResult();
const html = render(req.url, context, preloadedState);
const result = context.getResult();
if (result.redirect) {
res.redirect(result.redirect.pathname);
} else if (result.missed) {
// Do a second render pass with the context to clue <Miss>
// components into rendering this time.
// See: https://react-router.now.sh/ServerRouter
res.status(404).send(render(req.url, context, preloadedState));
} else {
res.send(html);
}
if (result.redirect) {
res.redirect(result.redirect.pathname);
} else if (result.missed) {
// Do a second render pass with the context to clue <Miss>
// components into rendering this time.
// See: https://react-router.now.sh/ServerRouter
res.status(404).send(render(req.url, context, preloadedState));
} else {
res.send(html);
}
})
.catch(e => {
console.error(e.message);
res.status(500).send(e.message);
});
});
app.listen(PORT, () => {

View file

@ -4,22 +4,78 @@
* https://github.com/erikras/ducks-modular-redux
*/
import { unionWith, isEqual } from 'lodash';
import { call, put, takeEvery } from 'redux-saga/effects';
import { createRequestTypes } from '../../util/sagaHelpers';
// ================ Action types ================ //
// Actions
export const ADD_FILTER = 'app/SearchPage/ADD_FILTER';
// Reducer
export default function reducer(state = {}, action = {}) {
// async actions use request - success / failure pattern
export const LOAD_LISTINGS = createRequestTypes('app/SearchPage/LOAD_LISTINGS');
// ================ Reducer ================ //
export const initialState = {
filters: [],
initialListingsLoaded: false,
listings: [],
loadingListings: false,
};
export default function reducer(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:
case LOAD_LISTINGS.SUCCESS:
case LOAD_LISTINGS.FAILURE:
return { ...state, ...payload };
default:
return state;
}
}
// Action Creators
// ================ Action creators ================ //
export const addFilter = (key, value) => ({ type: ADD_FILTER, payload: { [key]: value } });
// async actions use request - success - failure pattern
const action = (type, payload = {}) => ({ type, ...payload });
export const loadListings = {
request: () =>
action(LOAD_LISTINGS.REQUEST, {
payload: { loadingListings: true, initialListingsLoaded: false },
}),
success: listings =>
action(LOAD_LISTINGS.SUCCESS, {
payload: { loadingListings: false, initialListingsLoaded: true, listings },
}),
failure: error =>
action(LOAD_LISTINGS.FAILURE, {
payload: { loadingListings: false, loadListingError: error, error: true },
}),
};
// ================ Worker sagas ================ //
export function* callFetchListings(sdk) {
try {
const response = yield call(sdk.fetchListings);
yield put(loadListings.success(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);
}

View file

@ -1,9 +1,9 @@
import React, { PropTypes } from 'react';
import React, { Component, PropTypes } from 'react';
import classNames from 'classnames';
import { connect } from 'react-redux';
import { includes } from 'lodash';
import { addFlashNotification } from '../../ducks/FlashNotification.ducks';
import { addFilter } from './SearchPage.ducks';
import { addFilter, callFetchListings, loadListings } from './SearchPage.ducks';
import css from './SearchPage.css';
import {
FilterPanel,
@ -14,74 +14,72 @@ import {
SearchResultsPanel,
} from '../../components';
const fakeListings = [
{
id: 123,
title: 'Banyan Studios',
price: '55\u20AC / day',
description: 'Organic Music Production in a Sustainable, Ethical and Professional Studio.',
location: 'New York, NY \u2022 40mi away',
review: { count: '8 reviews', rating: '4' },
author: {
name: 'The Stardust Collective',
avatar: 'http://placehold.it/44x44',
review: { rating: '4' },
},
},
{
id: 1234,
title: 'Pienix Studio',
price: '80\u20AC day',
description: 'Pienix Studio specializes in music mixing and mastering production.',
location: 'New York, NY \u2022 6mi away',
review: { count: '7 reviews', rating: '4' },
author: { name: 'Juhan', avatar: 'http://placehold.it/44x44', review: { rating: '4' } },
},
];
export class SearchPageComponent extends Component {
componentDidMount() {
// TODO: This should be moved to Router
const { SearchPage } = this.props;
if (!SearchPage.initialListingsLoaded) {
this.props.onLoadListings();
}
}
export const SearchPageComponent = props => {
const { tab } = props;
const selectedTab = includes(['filters','listings', 'map'], tab)
? tab
: 'listings';
render() {
const { tab, SearchPage } = this.props;
const { listings } = SearchPage;
const fakeListings = listings || [];
const selectedTab = includes(['filters','listings', 'map'], tab)
? tab
: 'listings';
const filtersClassName = classNames(css.filters, {
[css.open]: selectedTab === 'filters',
});
const listingsClassName = classNames(css.filters, {
[css.open]: selectedTab === 'listings',
});
const mapClassName = classNames(css.filters, {
[css.open]: selectedTab === 'map',
});
const filtersClassName = classNames(css.filters, {
[css.open]: selectedTab === 'filters',
});
const listingsClassName = classNames(css.listings, {
[css.open]: selectedTab === 'listings',
});
const mapClassName = classNames(css.map, {
[css.open]: selectedTab === 'map',
});
return (
<PageLayout title="Search page">
<div className={css.container}>
<div className={filtersClassName}>
<FilterPanel />
return (
<PageLayout title="Search page">
<div className={css.container}>
<div className={filtersClassName}>
<FilterPanel />
</div>
<div className={listingsClassName}>
<SearchResultsPanel>
{fakeListings.map(l => <ListingCard key={l.id} {...l} />)}
</SearchResultsPanel>
</div>
<div className={mapClassName}>
<MapPanel>
{fakeListings.map(l => <ListingCardSmall key={l.id} {...l} />)}
</MapPanel>
</div>
</div>
<div className={listingsClassName}>
<SearchResultsPanel>
{fakeListings.map(l => <ListingCard key={l.id} {...l} />)}
</SearchResultsPanel>
</div>
<div className={mapClassName}>
<MapPanel>
{fakeListings.map(l => <ListingCardSmall key={l.id} {...l} />)}
</MapPanel>
</div>
</div>
</PageLayout>
);
</PageLayout>
);
}
}
SearchPageComponent.loadData = callFetchListings;
SearchPageComponent.defaultProps = { SearchPage: {}, tab: 'listings' };
const { array, bool, func, shape, string } = PropTypes;
SearchPageComponent.propTypes = {
onLoadListings: func.isRequired,
SearchPage: shape({
filters: array,
initialListingsLoaded: bool,
listings: array,
loadingListings: bool,
}),
tab: string,
};
SearchPageComponent.defaultProps = { tab: 'listings' };
const { string } = PropTypes;
SearchPageComponent.propTypes = { tab: string };
/**
* Container functions.
* Since we add this to global store state with combineReducers, this will only get partial state
@ -95,6 +93,7 @@ const mapDispatchToProps = function mapDispatchToProps(dispatch) {
return {
onAddNotice: msg => dispatch(addFlashNotification('notice', msg)),
onAddFilter: (k, v) => dispatch(addFilter(k, v)),
onLoadListings: () => dispatch(loadListings.request()),
};
};

View file

@ -2,7 +2,7 @@ import React from 'react';
import renderer from 'react-test-renderer';
import { TestProvider } from '../../util/test-helpers';
import { SearchPageComponent } from './SearchPage';
import reducer, { ADD_FILTER, addFilter } from './SearchPage.ducks';
import reducer, { ADD_FILTER, addFilter, initialState } from './SearchPage.ducks';
import { RoutesProvider } from '../../components';
import routesConfiguration from '../../routesConfiguration';
@ -11,7 +11,7 @@ describe('SearchPageComponent', () => {
const component = renderer.create(
<TestProvider>
<RoutesProvider routes={routesConfiguration}>
<SearchPageComponent />
<SearchPageComponent onLoadListings={v => v} />
</RoutesProvider>
</TestProvider>,
);
@ -31,7 +31,7 @@ describe('SearchPageDucs', () => {
describe('reducer', () => {
it('should return the initial state', () => {
const initial = reducer(undefined, {});
expect(initial).toEqual({});
expect(initial).toEqual(initialState);
});
it('should handle ADD_FILTER', () => {

View file

@ -155,174 +155,6 @@ exports[`SearchPageComponent matches snapshot 1`] = `
</span>
</div>
<div
className={undefined}>
<div
className={undefined}>
<div
className={undefined}>
<img
alt="Listing Title"
className={undefined}
src="http://placehold.it/400x300" />
</div>
</div>
<div
className={undefined}>
<div
className={undefined}>
<a
className=""
href="/l/Banyan-Studios/123"
onClick={[Function]}
style={Object {}}>
Banyan Studios
</a>
<div
className={undefined}>
55€ / day
</div>
</div>
<div
className={undefined}>
Organic Music Production in a Sustainable, Ethical and Professional Studio.
</div>
<div
className={undefined}>
<div
className={undefined}>
New York, NY • 40mi away
</div>
<div
className={undefined}>
(
<span>
4
</span>
<span>
/5
</span>
)
<span>
8 reviews
</span>
</div>
</div>
<hr />
<div
className={undefined}>
<div
className={undefined}>
<img
alt="The Stardust Collective"
className={undefined}
src="http://placehold.it/44x44" />
</div>
<div
className={undefined}>
<span
className={undefined}>
The Stardust Collective
</span>
<div
className={undefined}>
review:
<span>
4
</span>
<span>
/5
</span>
</div>
</div>
</div>
</div>
</div>
<div
className={undefined}>
<div
className={undefined}>
<div
className={undefined}>
<img
alt="Listing Title"
className={undefined}
src="http://placehold.it/400x300" />
</div>
</div>
<div
className={undefined}>
<div
className={undefined}>
<a
className=""
href="/l/Pienix-Studio/1234"
onClick={[Function]}
style={Object {}}>
Pienix Studio
</a>
<div
className={undefined}>
80€ day
</div>
</div>
<div
className={undefined}>
Pienix Studio specializes in music mixing and mastering production.
</div>
<div
className={undefined}>
<div
className={undefined}>
New York, NY • 6mi away
</div>
<div
className={undefined}>
(
<span>
4
</span>
<span>
/5
</span>
)
<span>
7 reviews
</span>
</div>
</div>
<hr />
<div
className={undefined}>
<div
className={undefined}>
<img
alt="Juhan"
className={undefined}
src="http://placehold.it/44x44" />
</div>
<div
className={undefined}>
<span
className={undefined}>
Juhan
</span>
<div
className={undefined}>
review:
<span>
4
</span>
<span>
/5
</span>
</div>
</div>
</div>
</div>
</div>
<div
className={undefined}>
<a
@ -350,92 +182,7 @@ exports[`SearchPageComponent matches snapshot 1`] = `
Map
</div>
<div
className={undefined}>
<div
className={undefined}>
<div
className={undefined}>
<div
className={undefined}>
<img
alt="Listing Title"
className={undefined}
src="http://placehold.it/200x150" />
</div>
</div>
<div
className={undefined}>
<a
className=""
href="/l/Banyan-Studios/123"
onClick={[Function]}
style={Object {}}>
Banyan Studios
</a>
<div
className={undefined}>
(
<span>
4
</span>
<span>
/5
</span>
)
<span>
8 reviews
</span>
</div>
<div
className={undefined}>
55€ / day
</div>
</div>
</div>
<div
className={undefined}>
<div
className={undefined}>
<div
className={undefined}>
<img
alt="Listing Title"
className={undefined}
src="http://placehold.it/200x150" />
</div>
</div>
<div
className={undefined}>
<a
className=""
href="/l/Pienix-Studio/1234"
onClick={[Function]}
style={Object {}}>
Pienix Studio
</a>
<div
className={undefined}>
(
<span>
4
</span>
<span>
/5
</span>
)
<span>
7 reviews
</span>
</div>
<div
className={undefined}>
80€ day
</div>
</div>
</div>
</div>
className={undefined} />
<a
className=""
href="/s/filters"

View file

@ -15,6 +15,8 @@ import React from 'react';
import ReactDOM from 'react-dom';
import { ClientApp, renderApp } from './app';
import configureStore from './store';
import { matchPathname } from './routesConfiguration';
import rootSaga from './sagas';
import './index.css';
@ -23,9 +25,15 @@ if (typeof window !== 'undefined') {
// eslint-disable-next-line no-underscore-dangle
const preloadedState = window.__PRELOADED_STATE__ || {};
const store = configureStore(preloadedState);
store.runSaga(rootSaga);
ReactDOM.render(<ClientApp store={store} />, document.getElementById('root'));
}
// Export the function for server side rendering.
export default renderApp;
// exporting matchPathname and configureStore for server side rendering.
// matchPathname helps to figure out which route is called and if it has preloading needs
// configureStore is used for creating initial store state for Redux after preloading
export { matchPathname, configureStore };

View file

@ -1,6 +1,10 @@
import React from 'react';
import { find } from 'lodash';
import { Redirect } from 'react-router';
// This will change to `matchPath` soonish
import matchPattern from 'react-router/matchPattern';
import pathToRegexp from 'path-to-regexp';
import {
AuthenticationPage,
@ -33,24 +37,28 @@ const routesConfiguration = [
exactly: true,
name: 'SearchPage',
component: SearchPage,
loadData: SearchPage ? SearchPage.loadData : null,
routes: [
{
pattern: '/s/filters',
exactly: true,
name: 'SearchFiltersPage',
component: props => <SearchPage {...props} tab="filters" />,
loadData: SearchPage ? SearchPage.loadData : null,
},
{
pattern: '/s/listings',
exactly: true,
name: 'SearchListingsPage',
component: props => <SearchPage {...props} tab="listings" />,
loadData: SearchPage ? SearchPage.loadData : null,
},
{
pattern: '/s/map',
exactly: true,
name: 'SearchMapPage',
component: props => <SearchPage {...props} tab="map" />,
loadData: SearchPage ? SearchPage.loadData : null,
},
],
},
@ -269,7 +277,45 @@ const toPathByRouteName = (nameToFind, routes) => {
const pathByRouteName = (nameToFind, routes, params = {}) =>
toPathByRouteName(nameToFind, routes)(params);
/**
* matchRoutesToLocation helps to figure out which routes are related to given location.
*/
const matchRoutesToLocation = (routes, location, matchedRoutes = [], params = {}) => {
let parameters = { ...params };
let matched = [...matchedRoutes];
routes.forEach(route => {
const { exactly = false } = route;
const match = !route.pattern ? true : matchPattern(route.pattern, location, exactly);
if (match) {
matched.push(route);
if (match.params) {
parameters = { ...parameters, ...match.params };
}
}
if (route.routes) {
const { matchedRoutes: subRouteMatches, params: subRouteParams } = matchRoutesToLocation(route.routes, location, matched, parameters);
matched = matched.concat(subRouteMatches);
parameters = { ...parameters, ...subRouteParams };
}
});
return { matchedRoutes: matched, params: parameters };
};
const matchPathnameCreator = routes =>
(location, matchedRoutes = [], params = {}) =>
matchRoutesToLocation(routes, { pathname: location }, matchedRoutes, params);
/**
* matchRoutesToLocation helps to figure out which routes are related to given location.
*/
const matchPathname = matchPathnameCreator(routesConfiguration);
// Exported helpers
export { findRouteByName, flattenRoutes, toPathByRouteName, pathByRouteName };
export { findRouteByName, flattenRoutes, matchPathname, toPathByRouteName, pathByRouteName };
export default routesConfiguration;

View file

@ -1,5 +1,7 @@
import { watchLogin, watchLogout } from './ducks/Auth.ducks';
import { watchLoadListings } from './containers/SearchPage/SearchPage.ducks';
import sdk from './util/fakeSDK';
export default function* rootSaga() {
yield [watchLogin(), watchLogout()];
yield [watchLogin(), watchLogout(), watchLoadListings(sdk)];
}

View file

@ -1,8 +1,7 @@
/* eslint-disable no-underscore-dangle */
import { createStore, applyMiddleware, compose } from 'redux';
import createSagaMiddleware from 'redux-saga';
import createSagaMiddleware, { END } from 'redux-saga';
import createReducer from './reducers';
import rootSaga from './sagas';
/**
* Create a new store with the given initial state. Adds Redux
@ -21,9 +20,10 @@ export default function configureStore(initialState = {}) {
: compose;
const enhancer = composeEnhancers(applyMiddleware(...middlewares));
const store = createStore(createReducer(), initialState, enhancer);
sagaMiddleware.run(rootSaga);
const store = createStore(createReducer(), initialState, enhancer);
store.runSaga = sagaMiddleware.run;
store.closeSagaMiddleware = () => store.dispatch(END);
return store;
}

39
src/util/fakeSDK.js Normal file
View file

@ -0,0 +1,39 @@
/* eslint-disable import/prefer-default-export, no-unused-vars */
export const fakeListings = [
{
id: 123,
title: 'Banyan Studios',
price: '55\u20AC / day',
description: 'Organic Music Production in a Sustainable, Ethical and Professional Studio.',
location: 'New York, NY \u2022 40mi away',
review: { count: '8 reviews', rating: '4' },
author: {
name: 'The Stardust Collective',
avatar: 'http://placehold.it/44x44',
review: { rating: '4' },
},
},
{
id: 1234,
title: 'Pienix Studio',
price: '80\u20AC / day',
description: 'Pienix Studio specializes in music mixing and mastering production.',
location: 'New York, NY \u2022 6mi away',
review: { count: '7 reviews', rating: '4' },
author: { name: 'Juhan', avatar: 'http://placehold.it/44x44', review: { rating: '4' } },
},
];
/* eslint-enable import/prefer-default-export */
const fakeSDK = {
fetchListings: () => {
const randomTime = Math.random() * 2000;
const fakeResponseTime = 1000 + randomTime;
return new Promise((resolve, reject) => {
setTimeout(() => resolve(fakeListings), fakeResponseTime);
});
},
};
export default fakeSDK;

14
src/util/sagaHelpers.js Normal file
View file

@ -0,0 +1,14 @@
/* eslint-disable import/prefer-default-export */
// async actions use request - success / failure pattern
const REQUEST = 'REQUEST';
const SUCCESS = 'SUCCESS';
const FAILURE = 'FAILURE';
// response format: { REQUEST: 'baseStr.REQUEST', SUCCESS: 'baseStr.SUCCESS', FAILURE: 'baseStr.FAILURE' }
export const createRequestTypes = base => [REQUEST, SUCCESS, FAILURE].reduce((acc, type) => {
// eslint-disable-next-line no-param-reassign
acc[type] = `${base}.${type}`;
return acc;
}, {});
/* eslint-enable import/prefer-default-export */