From 2a9c9e103bbdc9d57c2f74320fa2e7989909fecb Mon Sep 17 00:00:00 2001 From: Vesa Luusua Date: Fri, 3 Feb 2017 00:10:20 +0200 Subject: [PATCH] Fetch data with saga (both server and client) --- server/index.js | 75 ++++-- src/containers/SearchPage/SearchPage.ducks.js | 64 ++++- src/containers/SearchPage/SearchPage.js | 75 ++---- src/containers/SearchPage/SearchPage.test.js | 6 +- .../__snapshots__/SearchPage.test.js.snap | 255 +----------------- src/containers/SearchPage/fakeData.js | 26 ++ src/index.js | 6 +- src/routesConfiguration.js | 23 +- src/sagas.js | 3 +- src/store.js | 8 +- 10 files changed, 189 insertions(+), 352 deletions(-) create mode 100644 src/containers/SearchPage/fakeData.js diff --git a/server/index.js b/server/index.js index 233a6760..944d5920 100644 --- a/server/index.js +++ b/server/index.js @@ -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(/window.__PRELOADED_STATE__ = ${JSON.stringify(preloadedState).replace(/ + `; 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 - // 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 + // 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, () => { diff --git a/src/containers/SearchPage/SearchPage.ducks.js b/src/containers/SearchPage/SearchPage.ducks.js index f977d4a7..9f4bfa8d 100644 --- a/src/containers/SearchPage/SearchPage.ducks.js +++ b/src/containers/SearchPage/SearchPage.ducks.js @@ -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); + } +} diff --git a/src/containers/SearchPage/SearchPage.js b/src/containers/SearchPage/SearchPage.js index 938a512d..590e58f5 100644 --- a/src/containers/SearchPage/SearchPage.js +++ b/src/containers/SearchPage/SearchPage.js @@ -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 { ); } +} + +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()), }; }; diff --git a/src/containers/SearchPage/SearchPage.test.js b/src/containers/SearchPage/SearchPage.test.js index 5fbb94ea..172db280 100644 --- a/src/containers/SearchPage/SearchPage.test.js +++ b/src/containers/SearchPage/SearchPage.test.js @@ -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( - + v} /> , ); @@ -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', () => { diff --git a/src/containers/SearchPage/__snapshots__/SearchPage.test.js.snap b/src/containers/SearchPage/__snapshots__/SearchPage.test.js.snap index fe5450ef..1b45bcd7 100644 --- a/src/containers/SearchPage/__snapshots__/SearchPage.test.js.snap +++ b/src/containers/SearchPage/__snapshots__/SearchPage.test.js.snap @@ -155,174 +155,6 @@ exports[`SearchPageComponent matches snapshot 1`] = ` ▼ -
-
-
- Listing Title -
-
-
-
- - Banyan Studios - -
- 55€ / day -
-
-
- Organic Music Production in a Sustainable, Ethical and Professional Studio. -
-
-
- New York, NY • 40mi away -
-
- ( - - 4 - - - /5 - - ) - - - 8 reviews - -
-
-
-
-
- The Stardust Collective -
-
- - The Stardust Collective - -
- review: - - 4 - - - /5 - -
-
-
-
-
-
-
-
- Listing Title -
-
-
-
- - Pienix Studio - -
- 80€ day -
-
-
- Pienix Studio specializes in music mixing and mastering production. -
-
-
- New York, NY • 6mi away -
-
- ( - - 4 - - - /5 - - ) - - - 7 reviews - -
-
-
-
-
- Juhan -
-
- - Juhan - -
- review: - - 4 - - - /5 - -
-
-
-
-
-
-
-
- Listing Title -
-
-
- - Banyan Studios - -
- ( - - 4 - - - /5 - - ) - - - 8 reviews - -
-
- 55€ / day -
-
-
-
-
-
- Listing Title -
-
-
- - Pienix Studio - -
- ( - - 4 - - - /5 - - ) - - - 7 reviews - -
-
- 80€ day -
-
-
-
+ className={undefined} /> , 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 }; diff --git a/src/routesConfiguration.js b/src/routesConfiguration.js index 0c1367a6..16112dd8 100644 --- a/src/routesConfiguration.js +++ b/src/routesConfiguration.js @@ -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 => , + loadData: SearchPage.loadData, }, { pattern: '/s/listings', exactly: true, name: 'SearchListingsPage', component: props => , + loadData: SearchPage.loadData, }, { pattern: '/s/map', exactly: true, name: 'SearchMapPage', component: props => , + 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. diff --git a/src/sagas.js b/src/sagas.js index ae72fac3..d0a86084 100644 --- a/src/sagas.js +++ b/src/sagas.js @@ -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()]; } diff --git a/src/store.js b/src/store.js index ad4f25f8..405d6139 100644 --- a/src/store.js +++ b/src/store.js @@ -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; }