UNPKG

16.3 kBMarkdownView Raw
1# r/platform
2A set of tools to enable easy universal rendering and page navigation on a React + Redux stack.
3
4## Change Log
5#### v0.14.0
6Removed the `postRouteServerMiddleware` configuration option. Middleware will now end with the route handler being fired.
7
8## Installation
9Currently, just use NPM.
10```
11npm install -S @r/platform
12```
13
14You also need to install its peer dependencies. For example:
15```
16npm install koa@2.0.0 koa-bodyparser@3.0.0 koa-router@7.0.1 koa-static@3.0.0 react@15.0.1 react-redux@4.4.5 react-dom@15.0.0-rc.2 redux@3.4.0 reselect@2.4.0 lodash@4.11.1 @r/middleware@0.5.1
17```
18
19## Usage
20### Server
21```es6
22// Server.es6.js
23import Server from '@r/platform/Server';
24
25const server = Server({
26 reducers: {}, // Reducers for the Redux store.
27
28 routes: [], // A list of lists that maps
29 // routes to handlers. For example:
30 //
31 // [
32 // ['/', Frontpage],
33 // ['/r/:subredditName', Subreddit],
34 // ]
35
36 template: (data) => { /* ... */ }, // a template function that returns a
37 // string (likely an HTML string).
38
39 port: 8888, // OPTIONAL. port for your server.
40
41 preRouteServerMiddleware: [], // OPTIONAL. Koa middleware to run
42 // before a route is handled
43
44 reduxMiddleware: [], // OPTIONAL. Additional Redux
45 // middleware. Middleware defined here
46 // will run before r/platform's
47 // middleware runs.
48
49 dispatchBeforeNavigation: async (koaCtx, dispatch, getState, utils) => {},
50 // OPTIONAL. Dispatch additional
51 // actions before the navigation
52 // fires.
53
54 getServerRouter: (router) => {} // OPTIONAL. Return the Koa router if
55 // needed.
56});
57
58// start the server
59server();
60```
61
62### Client
63```es6
64// Client.es6.js
65import Client from '@r/platform/Client';
66
67const client = Client({
68 reducers: {}, // Reducers for the Redux store.
69
70 routes: [], // A list of lists that maps
71 // routes to handlers. For example:
72 //
73 // [
74 // ['/', Frontpage],
75 // ['/r/:subredditName', Subreddit],
76 // ]
77
78 appComponent: <div/>, // The React component that
79 // represents the app.
80
81 container: 'container', // OPTIONAL. Id of the DOM element
82 // the Client App will be rendered into.
83
84 dataVar: '___r', // OPTIONAL. A key on the 'window' object
85 // where the data will be written into.
86
87 modifyData: (data) => { /* ... */ }, // OPTIONAL. A function that mutates the
88 // data object before it is loaded
89 // into the client side store.
90
91 reduxMiddleware: [], // OPTIONAL. Additional Redux middleware.
92 // Middleware defined here will run
93 // before r/platform's middleware runs.
94
95 debug: false // OPTIONAL. Setting debug to true will
96 // cause redux actions to be logged
97 // in the console.
98});
99
100// run the client
101client();
102```
103
104## Creating Routes
105r/platform's router differs from most traditional routers. Instead of handlers returning html, they use Redux's dispatch calls to help define a state blob. Methods on the handler are HTTP verbs. Specifically, they are one of `get`, `post`, `put`, `patch`, and `delete`. These methods MUST return promises. The easiest way to enforce this is to declare the methods as es7 async functions.
106
107All methods have access to the following properties:
108
1090. `this.originalUrl`: the url that spawned this handler
1100. `this.urlParams`: a dictionary of route defined params. e.g. if '/bar' matches '/:foo', urlParams would look like `{ foo: 'bar' }`.
1110. `this.queryParams`: a dictionary of query params
1120. `this.hashParams`: a dictionary of hash params
1130. `this.bodyParams`: a dictionary of data that would appear in the request body
114
115Each method is also called with the following arguments:
116
1170. `dispatch`: a function used to dispatch Redux actions
1180. `getState`: a function that (when called) returns a snapshot of state in the Redux store
1190. `utils`: a dictionary of helper methods. Currently contains two methods, `waitForState` and `waitForAction`. Visit [r/middleware](https://github.com/nramadas/r-middleware) for more details on how these operate.
120
121### Example
122```es6
123// routes.es6.js
124import { BaseHandler, METHODS } from '@r/platform/router';
125import * as actions from '@r/platform/actions';
126import * as otherActions from './otherActions';
127
128// Create a handler
129class Frontpage extends BaseHandler {
130 async [METHODS.GET](dispatch, getState, { waitForState, waitForAction }) {
131 // pull out params if necessary
132 const { foo } = this.queryParams;
133
134 // dispatch certain actions synchronously
135 dispatch(otherActions.doSomething());
136
137 // if needed, wait on certain tasks to complete before dispatching further.
138 // on the Server side, the Server will wait for the entire function to
139 // complete before responding to the request with html.
140 const importantThing = await importantAsyncFunction();
141
142 // use the utility methods to wait on something in state
143 await waitForState(
144 state => state.foo === 'foo', // the condition
145 state => dispatch(/* something */) // the callback if condition is met
146 );
147
148 // further synchronous dispatches are possible. Thanks to es6/7, these won't
149 // fire until the previous asynchronous action has completed.
150 dispatch(/* something else */);
151 }
152}
153
154// Export the routes
155export default [
156 ['/', Frontpage],
157];
158```
159
160## Keeping the Url in Sync
161In addition to routing, it is important that the url is kept in sync with the store state. It is also important that when a popstate event is fired, the state updates to reflect. To that effect, r/platform exports a React component that manages the url. To use it, just drop the component into your app anywhere it won't get unmounted.
162
163```es6
164import React from 'react';
165import { UrlSync } from '@r/platform/components';
166
167export default class App extends React.Component {
168 render() {
169 return (
170 <div>
171 {/* many components */}
172 <UrlSync/>
173 </div>
174 )
175 }
176}
177```
178
179## Rendering pages
180Often, you would like to render certain components based on the url state. To do so, you can use the `<UrlSwitch>` component:
181
182```es6
183import React from 'react';
184import { UrlSwitch, Case, Page } from '@r/platform/url';
185
186export default class Foo extends React.Component {
187 render() {
188 return (
189 <div>
190 <UrlSwitch>
191 <Case
192 // do something based on a url. this is the most generic way to use
193 // urlSwitch
194 url='/'
195 exec={ pageData => <div/> }
196 />
197 <Page
198 // as a convenience, if a specific component needs to be rendered,
199 // use the <Page/> component instead. this takes a 'component'
200 // instead of a function. the props of the component are pageData
201 url='/r/:subredditName'
202 component={ FooComponent }
203 />
204 <Case
205 url='*' // catch all
206 exec={ pageData => <div/> }
207 />
208 </UrlSwitch>
209 </div>
210 );
211 }
212}
213```
214
215
216## Easy routing
217Sometimes, routing to a page might happen by clicking an anchor tag. Instead of manually connecting the anchor tag to a dispatch action, @r/platform exports a pre-connected anchor tag component:
218
219```es6
220import React from 'react';
221import { Anchor } from '@r/platform/components';
222
223export default class Foo extends React.Component {
224 render() {
225 return (
226 <div className='Foo'>
227 <Anchor
228 href='/foo?stuff=yeah'
229 className='Foo__anchor'
230 >
231 Click me!
232 </Anchor>
233 </div>
234 );
235 }
236}
237```
238
239@r/platform also includes a `<BackAnchor/>` component. The `<BackAnchor/>` checks to see if the linked url is the previous url in history. If it is, it calls `history.back()` (if the history API exists) instead of adding the destination to the browser's history. This makes links that say 'back' actually go back.
240
241If those don't suite your needs, @r/platform also provides `<LinkHijacker />`. Helpful for when you need to use `dangerouslySetInnerHTML`, this component will ensure clicking on links will navigate without a new page load. It follows relative links by default, and can be customized via a RegExp api to extract paths from arbitrary urls.
242
243```es6
244import React from 'react';
245import { LinkHijacker } from '@r/platform/components';
246
247export default class Foo extends React.Component {
248 render() {
249 return (
250 <div className='Foo'>
251 <LinkHijacker>
252 <div
253 className='Foo__content'
254 dangerouslySetInnerHTML={ { __html: this.props.htmlContent } }
255 />
256 </LinkHijacker>
257 </div>
258 );
259 }
260}
261```
262
263@r/platform exports a pre-connected form as well:
264
265```es6
266import React from 'react';
267import { Form } from '@r/platform/components';
268
269export default class Foo extends React.Component {
270 render() {
271 return (
272 <div className='Foo'>
273 <Form
274 action='/login'
275 className='Foo__form'
276 >
277 <input name='username'/>
278 <input name='password' type='password'/>
279 </Form>
280 </div>
281 );
282 }
283}
284```
285
286## Additional Tools
287There are a few additional goodies in r/platform
288
289**Reducer**
290
291r/platform exports a Redux reducer (`@r/platform/reducer`). This reducer gets auto added when using the `Client` and `Server` functions, so you should never need to import this directly.
292
293**Actions**
294
295r/platform exposes a few Redux actions you can use to navigate through the app. They are:
296
2970. `setPage(pageType, url, { urlParams, queryParams, hashParams })`: pushes a new page onto the navigation stack. Note: there are no bodyParams represented here, as routes that contain a body should not update the url.
2980. `gotoPageIndex(pageIndex)`: navigates to a particular page on the navigation stack.
2990. `navigateToUrl(method, pathName, { queryParams, hashParams, bodyParams })`: navigate to a url. Note: there is no need to independently include the urlParams here. Simply pass along the url.
300
301**Router**
302
303r/platform doesn't use a traditional router. So instead, the router exports a Handler and some http verbs.
304```es6
305import { BaseHandler, METHODS } from '@r/platform/router';
306
307console.log(METHODS); // {
308 // GET: 'get',
309 // POST: 'post',
310 // PUT: 'put',
311 // PATCH: 'patch',
312 // DELETE: 'delete',
313 // }
314
315console.log(BaseHandler); // Described in the previous section on creating routes.
316```
317
318**merge**
319
320r/platform includes a helpful utility method for "modifying" state while maintaining the immutability that Redux expects.
321```es6
322import merge from '@r/platform/merge';
323import * as actions from '@r/platform/actions';
324
325// reducer
326export default function(state={}, action={}) {
327 switch(action.type) {
328 case actions.GOTO_PAGE_INDEX: {
329 const { pageIndex } = action.payload;
330
331 // `merge` lets you just deal with state diffs. just merge your
332 // diff with state and `merge` will preserve immutability.
333 return merge(state, {
334 currentPageIndex: pageIndex,
335 currentPage: state.history[pageIndex],
336 });
337 }
338 default: return state;
339 }
340}
341```
342
343`merge` can also take options which let the method know how to deal with arrays and empty dictionaries.
344
345`merge(state, diff, options={ emptyDict, array })`
346
3470. `emptyDict`: One of `strict`, `skip`, or `replace`. Defaults to `strict`. `strict` will merge in the new dictionary, which will cause the object reference to change. `skip` will ignore empty dictionaries (thus not changing the object reference in the original). `replace` will swap out the old dictionary with the empty one.
348
3490. `array`: One of `replace` or `concat`. Defaults to `replace`. `replace` will swap out the old array with the new one. `concat` will produce a new array with values from both arrays, with values from the original taking precedence.
350
351**plugins**
352
353You may wish to quickly render a shell of the page- such as a loading screen-
354and make API requests on the client, rather than the server.
355
356```es6
357import * as plugins from '@r/platform/plugins';
358import Server from '@r/platform/Server';
359
360const server = Server({
361 //...
362 dispatchBeforeNavigation: async (ctx, dispatch, getState, utils) => {
363 //...
364 plugins.dispatchInitialShell(ctx, dispatch);
365 }
366});
367```
368
369This will set state.shell to `true` or `false`. If you have a `nojs` cookie, a
370`nojs` querystring, or your user-agent contains the word `bot`, state.shell
371will be `false` during server request handling. Otherwise, it will be `true`.
372You can then check `state.shell` in your _handlers_ to determine whether or not
373to make API requests.
374
375You will also likely want to run `actions.activateClient` on the client side to
376ensure the navigation actions are re-fired client side, with `shell` set to
377`false`. (Otherwise, activateClient is unnecessary unless you need to re-run
378navigation handlers for some reason.)
379
380```es6
381import * as actions from '@r/platform/actions';
382import Client from '@r/platform/Client';
383
384const client = Client({ /* ... */ });
385client.dispatch(actions.activateClient);
386```
387
388
389## Testing
390r/platform provides some hooks to make it easier to create tests. Primarily, it exports a test creator that lets you easily set up a test for a component:
391
392`createTest([storeOptions,] testFn)`
393
394`storeOptions` are optional and are used to make the store more representative of the actual store the component is wrapped with. It has three optional keys on it:
395
3960. `reducers: object`: A dictionary of any reducers the store should contain
3970. `middleware: array`: A list of middleware to be added to the store
3980. `routes: array`: A routes list
399
400The `testFn` is called with a dictionary of helpers: `{ shallow, mount, render, expect, getStore, sinon }`.
401
4020. `shallow: function`: Shallow renders your React components. Good for testing the rendering of the component and checking that certain elements exist within in. [more info](https://github.com/airbnb/enzyme/blob/master/docs/api/shallow.md)
4030. `mount: function`: Mounts the component on a jsdom document. Use this to test interactions like clicking, hover, etc. [more info](https://github.com/airbnb/enzyme/blob/master/docs/api/mount.md)
4040. `render: function`: Renders to static html. [more info](https://github.com/airbnb/enzyme/blob/master/docs/api/render.md)
4050. `expect: function`: Assertion function.
4060. `getStore: function`: Returns a store and a wrapper. Useful to testing components that depend on redux.
4070. `sinon: object`: The entirety of sinon to help generate spies, stubs, and mocks. [more info](http://sinonjs.org/)
408
409### Using createTest
410```es6
411import createTest from '@r/platform/createTest';
412import Foo from './Foo';
413
414// testing with a connected component
415createTest(({ mount, getStore, expect }) => {
416 describe('<Foo/>', () => {
417 it('should change state when clicked', () => {
418 const { store, StoreWrapper } = getStore();
419 const container = mount(
420 <StoreWrapper>
421 <Foo/>
422 </StoreWrapper>
423 );
424
425 container.find(Foo).simulate('click');
426 expect(store.getState().fooValue).to.equal('foo');
427 });
428 });
429});
430```