Merge pull request #71 from sharetribe/server-data-loading

Server side data loading: phase 1
This commit is contained in:
Kimmo Puputti 2017-03-16 14:30:44 +02:00 committed by GitHub
commit a5cf87c15d
31 changed files with 148 additions and 164 deletions

View file

@ -7,6 +7,7 @@
"basic-auth": "^1.1.0",
"classnames": "^2.2.5",
"compression": "^1.6.2",
"cookie-parser": "^1.4.3",
"express": "^4.15.2",
"helmet": "^3.5.0",
"lodash": "^4.17.4",
@ -41,7 +42,7 @@
"clean": "rm -rf build/*",
"dev": "sharetribe-scripts start",
"build": "sharetribe-scripts build",
"format": "prettier --write --print-width 100 --single-quote --trailing-comma all 'server/**/*.js' 'src/**/*.js'",
"format": "prettier --write --print-width 100 --single-quote --trailing-comma es5 'server/**/*.js' 'src/**/*.js'",
"test": "sharetribe-scripts test --env=jsdom",
"test-ci": "sharetribe-scripts test --env=jsdom --runInBand",
"eject": "sharetribe-scripts eject",

View file

@ -1,5 +1,37 @@
const { matchPathname } = require('./importer');
const url = require('url');
const { types } = require('sharetribe-sdk');
const { matchPathname, configureStore } = require('./importer');
exports.loadData = function(requestUrl) {
return Promise.resolve({});
// Currently the SDK serialisation doesn't work with mixed types from
// client bundle and server imports. To fix this temporarily, we wrap
// the id param here for the server side instead of doing the same in
// the loadData handler.
// TODO: remove this once the SDK serialisation works
const fixParams = params => {
if (params.id) {
return Object.assign({}, params, { id: new types.UUID(params.id) });
}
return params;
};
exports.loadData = function(requestUrl, sdk) {
const { pathname, query } = url.parse(requestUrl, true);
const matchedRoutes = matchPathname(pathname);
const store = configureStore(sdk);
const dataLoadingCalls = matchedRoutes.reduce(
(calls, match) => {
const { route, params } = match;
if (typeof route.loadData === 'function' && !route.auth) {
calls.push(store.dispatch(route.loadData(params, query)));
}
return calls;
},
[]
);
return Promise.all(dataLoadingCalls).then(() => {
return store.getState();
});
};

View file

@ -1,35 +0,0 @@
/* 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

@ -19,8 +19,10 @@ require('source-map-support').install();
const express = require('express');
const helmet = require('helmet');
const compression = require('compression');
const cookieParser = require('cookie-parser');
const path = require('path');
const qs = require('qs');
const sharetribeSdk = require('sharetribe-sdk');
const auth = require('./auth');
const renderer = require('./renderer');
const dataLoader = require('./dataLoader');
@ -29,6 +31,8 @@ const buildPath = path.resolve(__dirname, '..', 'build');
const env = process.env.NODE_ENV;
const dev = env !== 'production';
const PORT = process.env.PORT || 4000;
const CLIENT_ID = '08ec69f6-d37e-414d-83eb-324e94afddf0';
const BASE_URL = 'http://localhost:8088';
const app = express();
// The helmet middleware sets various HTTP headers to improve security.
@ -44,19 +48,29 @@ if (!dev) {
app.use(compression());
app.use('/static', express.static(path.join(buildPath, 'static')));
app.use(cookieParser());
app.get('*', (req, res) => {
const context = {};
const filters = qs.parse(req.query);
const sdk = sharetribeSdk.createInstance({
clientId: CLIENT_ID,
baseUrl: BASE_URL,
tokenStore: sharetribeSdk.tokenStore.expressCookieStore({
clientId: CLIENT_ID,
req,
res,
}),
});
dataLoader
.loadData(req.url)
.loadData(req.url, sdk)
.then(preloadedState => {
const html = renderer.render(req.url, context, preloadedState);
const debugData = {
url: req.url,
preloadedState,
context,
};

View file

@ -2,6 +2,7 @@ import React, { Component, PropTypes } from 'react';
import { compose } from 'redux';
import { connect } from 'react-redux';
import { Switch, Route, withRouter } from 'react-router-dom';
import { types } from 'sharetribe-sdk';
import { NotFoundPage } from './containers';
import { NamedRedirect } from './components';
import { withFlattenedRoutes } from './util/routes';
@ -9,6 +10,18 @@ import * as propTypes from './util/propTypes';
const { bool, arrayOf, object, func, shape, string, any } = PropTypes;
// Currently the SDK serialisation doesn't work with mixed types from
// client bundle and server imports. To fix this temporarily, we wrap
// the id param here for the client side instead of doing the same in
// the loadData handler.
// TODO: remove this once the SDK serialisation works
const fixParams = params => {
if (params.id) {
return { ...params, id: new types.UUID(params.id) };
}
return params;
};
class RouteComponentRenderer extends Component {
constructor(props) {
super(props);
@ -20,7 +33,7 @@ class RouteComponentRenderer extends Component {
const shouldLoadData = typeof loadData === 'function' && this.canShowComponent();
if (shouldLoadData) {
dispatch(loadData(match.params, {}))
dispatch(loadData(fixParams(match.params), {}))
.then(() => {
// eslint-disable-next-line no-console
console.log(`loadData success for ${name} route`);

View file

@ -65,7 +65,7 @@ ServerApp.propTypes = { url: string.isRequired, context: any.isRequired, store:
export const renderApp = (url, serverContext, preloadedState) => {
const store = configureStore(preloadedState);
const body = ReactDOMServer.renderToString(
<ServerApp url={url} context={serverContext} store={store} />,
<ServerApp url={url} context={serverContext} store={store} />
);
const head = Helmet.rewind();
return { head, body };

View file

@ -45,7 +45,7 @@ class AddImagesTest extends Component {
};
});
},
1000,
1000
);
}

View file

@ -10,7 +10,7 @@ describe('BookingInfo', () => {
bookingPeriod="Jan 2nd - Jan 4th"
bookingDuration="3 days"
total="165\u20AC"
/>,
/>
);
expect(tree).toMatchSnapshot();
});

View file

@ -7,7 +7,7 @@ describe('HeroSection', () => {
const tree = renderDeep(
<HeroSection params={{ displayName: 'most-awesome-shop' }}>
test
</HeroSection>,
</HeroSection>
);
expect(tree).toMatchSnapshot();
});

View file

@ -53,7 +53,7 @@ LocationPredictionsList.propTypes = {
id: string.isRequired,
description: string.isRequired,
place_id: string.isRequired,
}),
})
).isRequired,
highlightedIndex: number,
onSelectItem: func.isRequired,

View file

@ -26,7 +26,7 @@ describe('NamedLinkComponent', () => {
<div>
<NamedLinkComponent {...aProps}>link to a</NamedLinkComponent>
<NamedLinkComponent {...bProps}>link to b</NamedLinkComponent>
</div>,
</div>
);
const aLink = tree.children[0];
const bLink = tree.children[1];
@ -46,7 +46,7 @@ describe('NamedLink', () => {
const tree = renderDeep(
<RoutesProvider flattenedRoutes={routesConf}>
<NamedLink name="SomePage" params={{ id }}>to SomePage</NamedLink>
</RoutesProvider>,
</RoutesProvider>
);
expect(tree.type).toEqual('a');
expect(tree.props.href).toEqual(`/somepage/${id}`);

View file

@ -11,7 +11,7 @@ describe('RoutesProvider', () => {
Child.contextTypes = { flattenedRoutes: React.PropTypes.array };
const tree = renderDeep(
<RoutesProvider flattenedRoutes={routesConf}><Child /></RoutesProvider>,
<RoutesProvider flattenedRoutes={routesConf}><Child /></RoutesProvider>
);
expect(tree.children).toContain('SomePage');
});

View file

@ -79,7 +79,7 @@ const mapDispatchToProps = dispatch => ({
});
const AuthenticationPage = connect(mapStateToProps, mapDispatchToProps)(
AuthenticationPageComponent,
AuthenticationPageComponent
);
export default AuthenticationPage;

View file

@ -116,7 +116,7 @@ class EditListingForm extends Component {
{ id: 'EditListingForm.maxLength' },
{
maxLength: TITLE_MAX_LENGTH,
},
}
);
const maxLength60 = maxLength(maxLengthStr, TITLE_MAX_LENGTH);
const imageRequiredStr = intl.formatMessage({ id: 'EditListingForm.imageRequired' });

View file

@ -14,7 +14,7 @@ describe('EditListingForm', () => {
onImageUpload={v => v}
onSubmit={v => v}
onUpdateImageOrder={v => v}
/>,
/>
);
expect(tree).toMatchSnapshot();
});

View file

@ -15,7 +15,7 @@ describe('EditListingPageComponent', () => {
onUpdateImageOrder={v => v}
page={{ imageOrder: [], images: {} }}
type="new"
/>,
/>
);
expect(tree).toMatchSnapshot();
});

View file

@ -167,7 +167,11 @@ const mapStateToProps = state => ({
const ListingPage = connect(mapStateToProps)(injectIntl(ListingPageComponent));
ListingPage.loadData = params => {
return showListing(new UUID(params.id));
// TODO: wrap with UUID when the universal serialisation works with
// SDK and types from the client bundle and the server import don't
// differ
const id = params.id;
return showListing(id);
};
export default ListingPage;

View file

@ -17,7 +17,7 @@ describe('ListingPage', () => {
marketplaceData={marketplaceData}
intl={fakeIntl}
onLoadListing={l => l}
/>,
/>
);
expect(tree).toMatchSnapshot();
});
@ -57,7 +57,7 @@ describe('ListingPage', () => {
expect(e).toEqual(error);
expect(show.mock.calls).toEqual([[{ id, include: ['author', 'images'] }]]);
expect(dispatch.mock.calls).toEqual([[showListingRequest(id)], [showListingError(e)]]);
},
}
);
});
});

View file

@ -92,7 +92,7 @@ const lookupMap = included =>
return memo;
},
{},
{}
);
// Format the data as ListingCard component expects it and

View file

@ -18,7 +18,7 @@ const { LatLng } = types;
describe('SearchPageComponent', () => {
it('matches snapshot', () => {
const tree = renderShallow(
<SearchPageComponent onLoadListings={v => v} dispatch={() => null} />,
<SearchPageComponent onLoadListings={v => v} dispatch={() => null} />
);
expect(tree).toMatchSnapshot();
});

View file

@ -74,7 +74,7 @@ if (typeof window !== 'undefined') {
if (config.dev) {
const actions = bindActionCreators(
{ showListings, queryListings, searchListings, showMarketplace, showUsers },
store.dispatch,
store.dispatch
);
window.app = { config, sdk, sdkTypes: types, actions, store, sample, routeConfiguration };
}

View file

@ -48,7 +48,7 @@ export const updatedEntities = (oldEntities, apiResponse) => {
entities[type][id.uuid] = entity ? combinedResourceObjects(entity, curr) : curr;
return entities;
},
oldEntities,
oldEntities
);
/* eslint-enable no-param-reassign */
@ -100,7 +100,7 @@ export const denormalisedEntities = (entities, type, ids) => {
}
return ent;
},
entityData,
entityData
);
}
return entityData;

View file

@ -66,7 +66,7 @@ describe('data utils', () => {
const listing1 = { id: new UUID('listing1'), type: 'listing' };
const listing2 = { id: new UUID('listing2'), type: 'listing' };
expect(() => combinedResourceObjects(listing1, listing2)).toThrow(
'Cannot merge resource objects with different ids or types',
'Cannot merge resource objects with different ids or types'
);
});
@ -74,7 +74,7 @@ describe('data utils', () => {
const listing1 = { id: new UUID('listing1'), type: 'listing' };
const author1 = { id: new UUID('author1'), type: 'author' };
expect(() => combinedResourceObjects(listing1, author1)).toThrow(
'Cannot merge resource objects with different ids or types',
'Cannot merge resource objects with different ids or types'
);
});
@ -200,7 +200,7 @@ describe('data utils', () => {
it('throws when selecting a nonexistent type', () => {
const entities = { listing: { listing1: createListing('listing1') } };
expect(() => denormalisedEntities(entities, 'user', [new UUID('user1')])).toThrow(
'No entities of type user',
'No entities of type user'
);
});
@ -208,7 +208,7 @@ describe('data utils', () => {
const entities = { listing: { listing1: createListing('listing1') } };
const ids = [new UUID('listing2')];
expect(() => denormalisedEntities(entities, 'listing', ids)).toThrow(
'Entity listing with id listing2 not found',
'Entity listing with id listing2 not found'
);
});
@ -218,7 +218,7 @@ describe('data utils', () => {
const entities = { listing: { listing1 } };
const ids = [listing1.id];
expect(() => denormalisedEntities(entities, 'listing', ids)).toThrow(
'No entities of type user',
'No entities of type user'
);
});

View file

@ -1,39 +0,0 @@
/* 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;

View file

@ -15,7 +15,7 @@ const placeBounds = place => {
const sw = place.geometry.viewport.getSouthWest();
return new SDKLatLngBounds(
new SDKLatLng(ne.lat(), ne.lng()),
new SDKLatLng(sw.lat(), sw.lng()),
new SDKLatLng(sw.lat(), sw.lng())
);
}
return null;
@ -39,9 +39,7 @@ export const getPlaceDetails = placeId =>
service.getDetails({ placeId }, (place, status) => {
if (status !== serviceStatus.OK) {
reject(
new Error(
`Could not get details for place id "${placeId}", error status was "${status}"`,
),
new Error(`Could not get details for place id "${placeId}", error status was "${status}"`)
);
} else {
resolve({

View file

@ -73,7 +73,7 @@ export const image = shape({
height: number.isRequired,
name: string.isRequired,
url: string.isRequired,
}),
})
).isRequired,
}),
});

View file

@ -5,8 +5,21 @@ import pathToRegexp from 'path-to-regexp';
import routesConfiguration from '../routesConfiguration';
import * as propTypes from './propTypes';
export const flattenRoutes = routesArray =>
routesArray.reduce((a, b) => a.concat(b.routes ? [b].concat(flattenRoutes(b.routes)) : b), []);
// Flatten the routes config.
// TODO: flatten the original config and remove this function
export const flattenRoutes = routes =>
routes.reduce(
(flatRoutes, route) => {
const r = { ...route };
delete r.routes;
flatRoutes.push(r);
if (route.routes) {
return flatRoutes.concat(flattenRoutes(route.routes));
}
return flatRoutes;
},
[]
);
const findRouteByName = (nameToFind, flattenedRoutes) =>
find(flattenedRoutes, route => route.name === nameToFind);
@ -30,48 +43,33 @@ export const pathByRouteName = (nameToFind, flattenedRoutes, params = {}) =>
toPathByRouteName(nameToFind, flattenedRoutes)(params);
/**
* matchRoutesToLocation helps to figure out which routes are related to given location.
* Find the matching routes and their params for the given pathname
*
* @param {String} pathname - Full URL path from root with possible
* search params and hash included
*
* @return {Array<{ route, params }>} - All matches as { route, params } objects
*/
const matchRoutesToLocation = (routes, location, matchedRoutes = [], params = {}) => {
let parameters = { ...params };
let matched = [...matchedRoutes];
export const matchPathname = pathname => {
// TODO: remove flattening when routesConfiguration is flat
const flattenedRoutes = flattenRoutes(routesConfiguration);
routes.forEach(route => {
const { exact = false } = route;
const match = !route.path || matchPath(location.pathname, route.path, { exact });
if (match) {
matched.push(route);
if (match.params) {
parameters = { ...parameters, ...match.params };
return flattenedRoutes.reduce(
(matches, route) => {
const { exact = false } = route;
const match = !route.path || matchPath(pathname, route.path, { exact });
if (match) {
matches.push({
route,
params: 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 };
return matches;
},
[]
);
};
const matchPathnameCreator = routes =>
(location, matchedRoutes = [], params = {}) =>
matchRoutesToLocation(routes, { pathname: location }, matchedRoutes, params);
/**
* matchRoutesToLocation helps to figure out which routes are related to given location.
*/
export const matchPathname = matchPathnameCreator(routesConfiguration);
/**
* A higher order component (HOC) to take the flattened routes from
* the context that the RoutesProvider component has provided.

View file

@ -15,13 +15,13 @@ describe('withFlattenedRoutes', () => {
const shallowTree = renderShallow(
<RoutesProvider flattenedRoutes={routes}>
<Comp />
</RoutesProvider>,
</RoutesProvider>
);
expect(shallowTree).toMatchSnapshot();
const deepTree = renderDeep(
<RoutesProvider flattenedRoutes={routes}>
<Comp />
</RoutesProvider>,
</RoutesProvider>
);
expect(deepTree).toMatchSnapshot();
});

View file

@ -13,6 +13,6 @@ export const createRequestTypes = base =>
acc[type] = `${base}.${type}`;
return acc;
},
{},
{}
);
/* eslint-enable import/prefer-default-export */

View file

@ -45,7 +45,7 @@ export const renderDeep = component => {
const comp = renderer.create(
<TestProvider>
{component}
</TestProvider>,
</TestProvider>
);
return comp.toJSON();
};

View file

@ -1524,6 +1524,13 @@ convert-source-map@^1.1.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.4.0.tgz#e3dad195bf61bfe13a7a3c73e9876ec14a0268f3"
cookie-parser@^1.4.3:
version "1.4.3"
resolved "https://registry.yarnpkg.com/cookie-parser/-/cookie-parser-1.4.3.tgz#0fe31fa19d000b95f4aadf1f53fdc2b8a203baa5"
dependencies:
cookie "0.3.1"
cookie-signature "1.0.6"
cookie-signature@1.0.6:
version "1.0.6"
resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c"
@ -2042,16 +2049,7 @@ error-ex@^1.2.0:
dependencies:
is-arrayish "^0.2.1"
es-abstract@^1.6.1:
version "1.6.1"
resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.6.1.tgz#bb8a2064120abcf928a086ea3d9043114285ec99"
dependencies:
es-to-primitive "^1.1.1"
function-bind "^1.1.0"
is-callable "^1.1.3"
is-regex "^1.0.3"
es-abstract@^1.7.0:
es-abstract@^1.6.1, es-abstract@^1.7.0:
version "1.7.0"
resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.7.0.tgz#dfade774e01bfcd97f96180298c449c8623fb94c"
dependencies: