diff --git a/package.json b/package.json index c5d7aeef..e4420155 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/public/index.html b/public/index.html index d095f837..edc9c714 100644 --- a/public/index.html +++ b/public/index.html @@ -5,7 +5,7 @@ - +
diff --git a/server/fakeSDK.js b/server/fakeSDK.js new file mode 100644 index 00000000..86effa36 --- /dev/null +++ b/server/fakeSDK.js @@ -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); + }); +}; diff --git a/server/index.js b/server/index.js index a2cc2d6c..9670ce53 100644 --- a/server/index.js +++ b/server/index.js @@ -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(/window.__PRELOADED_STATE__ = ${serializedState}; `; 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 - // 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..68626377 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 { 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); +} diff --git a/src/containers/SearchPage/SearchPage.js b/src/containers/SearchPage/SearchPage.js index 5aeb1d17..1b48c7fd 100644 --- a/src/containers/SearchPage/SearchPage.js +++ b/src/containers/SearchPage/SearchPage.js @@ -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 ( - -
-
- + return ( + +
+
+ +
+
+ + {fakeListings.map(l => )} + +
+
+ + {fakeListings.map(l => )} + +
-
- - {fakeListings.map(l => )} - -
-
- - {fakeListings.map(l => )} - -
-
- - ); + + ); + } +} + +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()), }; }; 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')); } // 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 }; diff --git a/src/routesConfiguration.js b/src/routesConfiguration.js index a57a7144..09df1016 100644 --- a/src/routesConfiguration.js +++ b/src/routesConfiguration.js @@ -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 => , + loadData: SearchPage ? SearchPage.loadData : null, }, { pattern: '/s/listings', exactly: true, name: 'SearchListingsPage', component: props => , + loadData: SearchPage ? SearchPage.loadData : null, }, { pattern: '/s/map', exactly: true, name: 'SearchMapPage', component: props => , + 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; diff --git a/src/sagas.js b/src/sagas.js index ae72fac3..f1df18e3 100644 --- a/src/sagas.js +++ b/src/sagas.js @@ -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)]; } diff --git a/src/store.js b/src/store.js index ad4f25f8..a431d492 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.closeSagaMiddleware = () => store.dispatch(END); return store; } diff --git a/src/util/fakeSDK.js b/src/util/fakeSDK.js new file mode 100644 index 00000000..c298a311 --- /dev/null +++ b/src/util/fakeSDK.js @@ -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; diff --git a/src/util/sagaHelpers.js b/src/util/sagaHelpers.js new file mode 100644 index 00000000..099ad65a --- /dev/null +++ b/src/util/sagaHelpers.js @@ -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 */