UNPKG

15.5 kBMarkdownView Raw
1# r/platform
2A set of tools to enable easy universal rendering and page navigation on a React + Redux stack.
3
4## Installation
5Currently, just use NPM.
6```
7npm install -S @r/platform
8```
9
10You also need to install its peer dependencies. For example:
11```
12npm 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
13```
14
15## Usage
16### Server
17```es6
18// Server.es6.js
19import Server from '@r/platform/Server';
20
21const server = Server({
22 reducers={}, // Reducers for the Redux store.
23
24 routes=[], // A list of lists that maps
25 // routes to handlers. For example:
26 //
27 // [
28 // ['/', Frontpage],
29 // ['/r/:subredditName', Subreddit],
30 // ]
31
32 template=function(data) {...}, // a template function that returns a
33 // string (likely an HTML string).
34
35 port=8888, // OPTIONAL. port for your server.
36
37 preRouteServerMiddleware=[], // OPTIONAL. Koa middleware to run
38 // before a route is handled
39
40 postRouteServerMiddleware=[], // OPTIONAL. Koa middleware to run
41 // after a route is handled
42
43 reduxMiddleware=[], // OPTIONAL. Additional Redux
44 // middleware. Middleware defined here
45 // will run before r/platform's
46 // middleware runs.
47
48 dispatchBeforeNavigation=async (koaCtx, dispatch, getState, utils) => {},
49 // OPTIONAL. Dispatch additional
50 // actions before the navigation
51 // fires.
52
53 getServerRouter=(router) => {}, // OPTIONAL. Return the Koa router if
54 // needed.
55});
56
57// start the server
58server();
59```
60
61### Client
62```es6
63// Client.es6.js
64import Client from '@r/platform/Client';
65
66const client = Client({
67 reducers={}, // Reducers for the Redux store.
68
69 routes=[], // A list of lists that maps
70 // routes to handlers. For example:
71 //
72 // [
73 // ['/', Frontpage],
74 // ['/r/:subredditName', Subreddit],
75 // ]
76
77 appComponent=<div/> // The React component that
78 // represents the app.
79
80 container='container', // OPTIONAL. Id of the DOM element
81 // the Client App will be rendered into.
82
83 dataVar='___r', // OPTIONAL. A key on the 'window' object
84 // where the data will be written into.
85
86 modifyData=function(data) {...} // OPTIONAL. A function that mutates the
87 // data object before it is loaded
88 // into the client side store.
89
90 reduxMiddleware=[], // OPTIONAL. Additional Redux middleware.
91 // Middleware defined here will run
92 // before r/platform's middleware runs.
93
94 debug=false, // OPTIONAL. Setting debug to true will
95 // cause redux actions to be logged
96 // in the console.
97});
98
99// run the client
100client();
101```
102
103## Creating Routes
104r/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.
105
106All methods have access to the following properties:
107
1080. `this.originalUrl`: the url that spawned this handler
1090. `this.urlParams`: a dictionary of route defined params. e.g. if '/bar' matches '/:foo', urlParams would look like `{ foo: 'bar' }`.
1100. `this.queryParams`: a dictionary of query params
1110. `this.hashParams`: a dictionary of hash params
1120. `this.bodyParams`: a dictionary of data that would appear in the request body
113
114Each method is also called with the following arguments:
115
1160. `dispatch`: a function used to dispatch Redux actions
1170. `getState`: a function that (when called) returns a snapshot of state in the Redux store
1180. `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.
119
120### Example
121```es6
122// routes.es6.js
123import { BaseHandler, METHODS } from '@r/platform/router';
124import * as actions from '@r/platform/actions';
125import * as otherActions from './otherActions';
126
127// Create a handler
128class Frontpage extends BaseHandler {
129 async [METHODS.GET](dispatch, getState, { waitForState, waitForAction }) {
130 // pull out params if necessary
131 const { foo } = this.queryParams;
132
133 // dispatch certain actions synchronously
134 dispatch(otherActions.doSomething());
135
136 // if needed, wait on certain tasks to complete before dispatching further.
137 // on the Server side, the Server will wait for the entire function to
138 // complete before responding to the request with html.
139 const importantThing = await importantAsyncFunction();
140
141 // use the utility methods to wait on something in state
142 await waitForState(
143 state => state.foo === 'foo', // the condition
144 state => dispatch(/* something */) // the callback if condition is met
145 );
146
147 // further synchronous dispatches are possible. Thanks to es6/7, these won't
148 // fire until the previous asynchronous action has completed.
149 dispatch(/* something else */);
150 }
151}
152
153// Export the routes
154export default [
155 ['/', Frontpage],
156];
157```
158
159## Keeping the Url in Sync
160In 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.
161
162```es6
163import React from 'react';
164import { UrlSync } from '@r/platform/components';
165
166export default class App extends React.Component {
167 render() {
168 return (
169 <div>
170 {/* many components */}
171 <UrlSync/>
172 </div>
173 )
174 }
175}
176```
177
178## Rendering pages
179Often, you would like to render certain components based on the url state. To do so, you can use the `<UrlSwitch>` component:
180
181```es6
182import React from 'react';
183import { UrlSwitch, Case, Page } from '@r/platform/url';
184
185export default class Foo extends React.Component {
186 render() {
187 return (
188 <div>
189 <UrlSwitch>
190 <Case
191 // do something based on a url. this is the most generic way to use
192 // urlSwitch
193 url='/'
194 exec={ pageData => <div/> }
195 />
196 <Page
197 // as a convenience, if a specific component needs to be rendered,
198 // use the <Page/> component instead. this takes a 'component'
199 // instead of a function. the props of the component are pageData
200 url='/r/:subredditName'
201 component={ FooComponent }
202 />
203 <Case
204 url='*' // catch all
205 exec={ pageData => <div/> }
206 />
207 </UrlSwitch>
208 </div>
209 );
210 }
211}
212```
213
214
215## Easy routing
216Sometimes, 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:
217
218```es6
219import React from 'react';
220import { Anchor } from '@r/platform/components';
221
222export default class Foo extends React.Component {
223 render() {
224 return (
225 <div className='Foo'>
226 <Anchor
227 href='/foo?stuff=yeah'
228 className='Foo__anchor'
229 >
230 Click me!
231 </Anchor>
232 </div>
233 );
234 }
235}
236```
237
238@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.
239
240@r/platform exports a pre-connected form as well:
241
242```es6
243import React from 'react';
244import { Form } from '@r/platform/components';
245
246export default class Foo extends React.Component {
247 render() {
248 return (
249 <div className='Foo'>
250 <Form
251 action='/login'
252 className='Foo__form'
253 >
254 <input name='username'/>
255 <input name='password' type='password'/>
256 </Form>
257 </div>
258 );
259 }
260}
261```
262
263## Additional Tools
264There are a few additional goodies in r/platform
265
266**Reducer**
267
268r/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.
269
270**Actions**
271
272r/platform exposes a few Redux actions you can use to navigate through the app. They are:
273
2740. `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.
2750. `gotoPageIndex(pageIndex)`: navigates to a particular page on the navigation stack.
2760. `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.
277
278**Router**
279
280r/platform doesn't use a traditional router. So instead, the router exports a Handler and some http verbs.
281```es6
282import { BaseHandler, METHODS } from '@r/platform/router';
283
284console.log(METHODS); // {
285 // GET: 'get',
286 // POST: 'post',
287 // PUT: 'put',
288 // PATCH: 'patch',
289 // DELETE: 'delete',
290 // }
291
292console.log(BaseHandler); // Described in the previous section on creating routes.
293```
294
295**merge**
296
297r/platform includes a helpful utility method for "modifying" state while maintaining the immutability that Redux expects.
298```es6
299import merge from '@r/platform/merge';
300import * as actions from '@r/platform/actions';
301
302// reducer
303export default function(state={}, action={}) {
304 switch(action.type) {
305 case actions.GOTO_PAGE_INDEX: {
306 const { pageIndex } = action.payload;
307
308 // `merge` lets you just deal with state diffs. just merge your
309 // diff with state and `merge` will preserve immutability.
310 return merge(state, {
311 currentPageIndex: pageIndex,
312 currentPage: state.history[pageIndex],
313 });
314 }
315 default: return state;
316 }
317}
318```
319
320`merge` can also take options which let the method know how to deal with arrays and empty dictionaries.
321
322`merge(state, diff, options={ emptyDict, array })`
323
3240. `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.
325
3260. `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.
327
328**plugins**
329
330You may wish to quickly render a shell of the page- such as a loading screen-
331and make API requests on the client, rather than the server.
332
333```es6
334import * as plugins from '@r/platform/plugins';
335import Server from '@r/platform/Server';
336
337const server = Server({
338 //...
339 dispatchBeforeNavigation: async (ctx, dispatch, getState, utils) => {
340 //...
341 plugins.dispatchInitialShell(ctx, dispatch);
342 }
343});
344```
345
346This will set state.shell to `true` or `false`. If you have a `nojs` cookie, a
347`nojs` querystring, or your user-agent contains the word `bot`, state.shell
348will be `false` during server request handling. Otherwise, it will be `true`.
349You can then check `state.shell` in your _handlers_ to determine whether or not
350to make API requests.
351
352You will also likely want to run `actions.activateClient` on the client side to
353ensure the navigation actions are re-fired client side, with `shell` set to
354`false`. (Otherwise, activateClient is unnecessary unless you need to re-run
355navigation handlers for some reason.)
356
357```es6
358import * as actions from '@r/platform/actions';
359import Client from '@r/platform/Client';
360
361const client = Client({ /* ... */ });
362client.dispatch(actions.activateClient);
363```
364
365
366## Testing
367r/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:
368
369`createTest([storeOptions,] testFn)`
370
371`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:
372
3730. `reducers: object`: A dictionary of any reducers the store should contain
3740. `middleware: array`: A list of middleware to be added to the store
3750. `routes: array`: A routes list
376
377The `testFn` is called with a dictionary of helpers: `{ shallow, mount, render, expect, getStore, sinon }`.
378
3790. `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)
3800. `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)
3810. `render: function`: Renders to static html. [more info](https://github.com/airbnb/enzyme/blob/master/docs/api/render.md)
3820. `expect: function`: Assertion function.
3830. `getStore: function`: Returns a store and a wrapper. Useful to testing components that depend on redux.
3840. `sinon: object`: The entirety of sinon to help generate spies, stubs, and mocks. [more info](http://sinonjs.org/)
385
386### Using createTest
387```es6
388import createTest from '@r/platform/createTest';
389import Foo from './Foo';
390
391// testing with a connected component
392createTest(({ mount, getStore, expect }) => {
393 describe('<Foo/>', () => {
394 it('should change state when clicked', () => {
395 const { store, StoreWrapper } = getStore();
396 const container = mount(
397 <StoreWrapper>
398 <Foo/>
399 </StoreWrapper>
400 );
401
402 container.find(Foo).simulate('click');
403 expect(store.getState().fooValue).to.equal('foo');
404 });
405 });
406});
407```