Fetch data with saga (both server and client)

This commit is contained in:
Vesa Luusua 2017-02-03 00:10:20 +02:00
parent 3c597ea2cf
commit 2a9c9e103b
10 changed files with 189 additions and 352 deletions

View file

@ -27,6 +27,7 @@ const _ = require('lodash');
const React = require('react');
const { createServerRenderContext } = require('react-router');
const auth = require('./auth');
const sagaEffects = require('redux-saga/effects');
// Construct the bundle path where the server side rendering function
// can be imported.
@ -37,6 +38,7 @@ const mainJsPath = path.join(buildPath, manifest['main.js']);
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
@ -69,28 +71,46 @@ const template = _.template(indexHtml, {
escape: reNoMatch,
});
function render(requestUrl, context, preloadedState) {
const { head, body } = renderApp(requestUrl, context, preloadedState);
function fetchInitialState(requestUrl) {
const pathname = url.parse(requestUrl).pathname;
const { matchedRoutes, params } = matchPathname(pathname);
const component = matchedRoutes[0].component;
const initialFetches = _
.chain(matchedRoutes)
.filter(r => r.loadData && r.loadData.fetchData)
.map(r => sagaEffects.fork(r.loadData.fetchData))
.value();
if (component.loadData) {
component
.loadData()
.then((val) => {
console.log('Data fetch resolved', val);
});
const fetchInitialData = function* fetchInitialData() {
yield initialFetches;
};
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);
});
store.close();
});
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 = `
<script>window.__PRELOADED_STATE__ = ${JSON.stringify(preloadedState).replace(/</g, '\\u003c')};</script>
<script>window.__PRELOADED_STATE__ = ${serializedState};</script>
`;
return template({ title: head.title.toString(), preloadedStateScript, body });
@ -120,21 +140,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 { delay } from 'redux-saga';
import { call, take, put } from 'redux-saga/effects';
import { fakeListings } from './fakeData';
// ================ Action types ================ //
// Actions
export const ADD_FILTER = 'app/SearchPage/ADD_FILTER';
export const LOAD_LISTINGS = 'app/SearchPage/LOAD_LISTINGS';
export const LOAD_LISTINGS_SUCCESS = 'app/SearchPage/LOAD_LISTINGS_SUCCESS';
export const LOAD_LISTINGS_FAILURE = 'app/SearchPage/LOAD_LISTINGS_FAILURE';
// Reducer
export default function reducer(state = {}, action = {}) {
// ================ 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:
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, loadListingError: payload };
default:
return state;
}
}
// Action Creators
// ================ Action creators ================ //
export const addFilter = (key, value) => ({ type: ADD_FILTER, payload: { [key]: value } });
export const loadListings = () => ({ type: LOAD_LISTINGS, payload: {} });
export const loadListingsSuccess = listings => ({ type: LOAD_LISTINGS_SUCCESS, payload: listings });
export const loadListingsFailure = error => ({
type: LOAD_LISTINGS_FAILURE,
payload: error,
error: true,
});
// ================ Worker sagas ================ //
export function fetchListings() {
const randomTime = Math.random() * 2000;
const fakeResponseTime = 1000 + randomTime;
return delay(fakeResponseTime, { response: fakeListings });
}
export function* callFetchListings() {
const { response, error } = yield call(fetchListings);
if (response) {
yield put(loadListingsSuccess(response));
} else {
yield put(loadListingsFailure(error));
}
}
// ================ Watcher sagas ================ //
export function* watchLoadListings() {
// eslint-disable-next-line no-constant-condition
while (true) {
yield take(LOAD_LISTINGS);
yield call(callFetchListings);
}
}

View file

@ -3,7 +3,7 @@ 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,44 +14,16 @@ 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 {
componentWillMount() {
/* eslint-disable no-console */
console.log('Client loads data');
SearchPageComponent.loadData().then((val) => {
console.log('Client fetched data', val);
});
/* eslint-enable no-console */
componentDidMount() {
// TODO: This should be moved to Router
this.props.onLoadListings();
}
render() {
const { tab } = this.props;
const { tab, SearchPage } = this.props;
const { listings } = SearchPage;
const fakeListings = listings || [];
const selectedTab = includes(['filters','listings', 'map'], tab)
? tab
: 'listings';
@ -86,23 +58,25 @@ export class SearchPageComponent extends Component {
</PageLayout>
);
}
}
SearchPageComponent.loadData = { fetchData: callFetchListings, fetchDataAction: loadListings };
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 };
SearchPageComponent.loadData = () => (
new Promise((resolve) => (
// also node has a 'setTimeout' -> it's good for testing async call.
setTimeout(() => {
resolve(fakeListings);
}, (Math.random() * 2000) + 1000)
))
);
/**
* Container functions.
* Since we add this to global store state with combineReducers, this will only get partial state
@ -116,6 +90,7 @@ const mapDispatchToProps = function mapDispatchToProps(dispatch) {
return {
onAddNotice: msg => dispatch(addFlashNotification('notice', msg)),
onAddFilter: (k, v) => dispatch(addFilter(k, v)),
onLoadListings: () => dispatch(loadListings()),
};
};

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

@ -0,0 +1,26 @@
/* eslint-disable import/prefer-default-export */
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 */

View file

@ -15,7 +15,8 @@ import React from 'react';
import ReactDOM from 'react-dom';
import { ClientApp, renderApp } from './app';
import configureStore from './store';
import { matchLocation } from './routesConfiguration';
import { matchPathname } from './routesConfiguration';
import rootSaga from './sagas';
import './index.css';
@ -24,6 +25,7 @@ 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'));
}
@ -31,4 +33,4 @@ if (typeof window !== 'undefined') {
// Export the function for server side rendering.
export default renderApp;
export { matchLocation };
export { matchPathname, configureStore };

View file

@ -3,7 +3,7 @@ import { find } from 'lodash';
import { Redirect } from 'react-router';
// This will change to `matchPath` soonish
import matchPattern from 'react-router/matchPattern'
import matchPattern from 'react-router/matchPattern';
import pathToRegexp from 'path-to-regexp';
import {
@ -37,24 +37,28 @@ const routesConfiguration = [
exactly: true,
name: 'SearchPage',
component: SearchPage,
loadData: SearchPage.loadData,
routes: [
{
pattern: '/s/filters',
exactly: true,
name: 'SearchFiltersPage',
component: props => <SearchPage {...props} tab="filters" />,
loadData: SearchPage.loadData,
},
{
pattern: '/s/listings',
exactly: true,
name: 'SearchListingsPage',
component: props => <SearchPage {...props} tab="listings" />,
loadData: SearchPage.loadData,
},
{
pattern: '/s/map',
exactly: true,
name: 'SearchMapPage',
component: props => <SearchPage {...props} tab="map" />,
loadData: SearchPage.loadData,
},
],
},
@ -276,9 +280,9 @@ const pathByRouteName = (nameToFind, routes, params = {}) =>
/**
* matchRoutesToLocation helps to figure out which routes are related to given location.
*/
const matchRoutesToLocation = (routes, location, matchedRoutes=[], params={}) => {
const matchRoutesToLocation = (routes, location, matchedRoutes = [], params = {}) => {
const parameters = { ...params };
routes.forEach((route) => {
routes.forEach(route => {
const { exactly = false } = route;
const match = !route.pattern ? true : matchPattern(route.pattern, location, exactly);
@ -286,7 +290,9 @@ const matchRoutesToLocation = (routes, location, matchedRoutes=[], params={}) =>
matchedRoutes.push(route);
if (match.params) {
Object.keys(match.params).forEach(key => { parameters[key] = match.params[key]; });
Object.keys(match.params).forEach(key => {
parameters[key] = match.params[key];
});
}
}
@ -296,12 +302,11 @@ const matchRoutesToLocation = (routes, location, matchedRoutes=[], params={}) =>
});
return { matchedRoutes, params: parameters };
}
};
const matchPathnameCreator = routes => (
(location, matchedRoutes=[], params={}) =>
matchRoutesToLocation(routes, { pathname: location }, matchedRoutes, params)
);
const matchPathnameCreator = routes =>
(location, matchedRoutes = [], params = {}) =>
matchRoutesToLocation(routes, { pathname: location }, matchedRoutes, params);
/**
* matchRoutesToLocation helps to figure out which routes are related to given location.

View file

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

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.close = () => store.dispatch(END);
return store;
}