1 | # r/platform
|
2 | A set of tools to enable easy universal rendering and page navigation on a React + Redux stack.
|
3 |
|
4 | ## Installation
|
5 | Currently, just use NPM.
|
6 | ```
|
7 | npm install -S @r/platform
|
8 | ```
|
9 |
|
10 | You also need to install its peer dependencies. For example:
|
11 | ```
|
12 | npm 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
|
19 | import Server from '@r/platform/Server';
|
20 |
|
21 | const 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
|
58 | server();
|
59 | ```
|
60 |
|
61 | ### Client
|
62 | ```es6
|
63 | // Client.es6.js
|
64 | import Client from '@r/platform/Client';
|
65 |
|
66 | const 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
|
100 | client();
|
101 | ```
|
102 |
|
103 | ## Creating Routes
|
104 | r/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 |
|
106 | All methods have access to the following properties:
|
107 |
|
108 | 0. `this.originalUrl`: the url that spawned this handler
|
109 | 0. `this.urlParams`: a dictionary of route defined params. e.g. if '/bar' matches '/:foo', urlParams would look like `{ foo: 'bar' }`.
|
110 | 0. `this.queryParams`: a dictionary of query params
|
111 | 0. `this.hashParams`: a dictionary of hash params
|
112 | 0. `this.bodyParams`: a dictionary of data that would appear in the request body
|
113 |
|
114 | Each method is also called with the following arguments:
|
115 |
|
116 | 0. `dispatch`: a function used to dispatch Redux actions
|
117 | 0. `getState`: a function that (when called) returns a snapshot of state in the Redux store
|
118 | 0. `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
|
123 | import { BaseHandler, METHODS } from '@r/platform/router';
|
124 | import * as actions from '@r/platform/actions';
|
125 | import * as otherActions from './otherActions';
|
126 |
|
127 | // Create a handler
|
128 | class 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
|
154 | export default [
|
155 | ['/', Frontpage],
|
156 | ];
|
157 | ```
|
158 |
|
159 | ## Keeping the Url in Sync
|
160 | In 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
|
163 | import React from 'react';
|
164 | import { UrlSync } from '@r/platform/components';
|
165 |
|
166 | export 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
|
179 | Often, you would like to render certain components based on the url state. To do so, you can use the `<UrlSwitch>` component:
|
180 |
|
181 | ```es6
|
182 | import React from 'react';
|
183 | import { UrlSwitch, Case, Page } from '@r/platform/url';
|
184 |
|
185 | export 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
|
216 | Sometimes, 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
|
219 | import React from 'react';
|
220 | import { Anchor } from '@r/platform/components';
|
221 |
|
222 | export 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
|
243 | import React from 'react';
|
244 | import { Form } from '@r/platform/components';
|
245 |
|
246 | export 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
|
264 | There are a few additional goodies in r/platform
|
265 |
|
266 | **Reducer**
|
267 |
|
268 | r/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 |
|
272 | r/platform exposes a few Redux actions you can use to navigate through the app. They are:
|
273 |
|
274 | 0. `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.
|
275 | 0. `gotoPageIndex(pageIndex)`: navigates to a particular page on the navigation stack.
|
276 | 0. `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 |
|
280 | r/platform doesn't use a traditional router. So instead, the router exports a Handler and some http verbs.
|
281 | ```es6
|
282 | import { BaseHandler, METHODS } from '@r/platform/router';
|
283 |
|
284 | console.log(METHODS); // {
|
285 | // GET: 'get',
|
286 | // POST: 'post',
|
287 | // PUT: 'put',
|
288 | // PATCH: 'patch',
|
289 | // DELETE: 'delete',
|
290 | // }
|
291 |
|
292 | console.log(BaseHandler); // Described in the previous section on creating routes.
|
293 | ```
|
294 |
|
295 | **merge**
|
296 |
|
297 | r/platform includes a helpful utility method for "modifying" state while maintaining the immutability that Redux expects.
|
298 | ```es6
|
299 | import merge from '@r/platform/merge';
|
300 | import * as actions from '@r/platform/actions';
|
301 |
|
302 | // reducer
|
303 | export 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 |
|
324 | 0. `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 |
|
326 | 0. `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 |
|
330 | You may wish to quickly render a shell of the page- such as a loading screen-
|
331 | and make API requests on the client, rather than the server.
|
332 |
|
333 | ```es6
|
334 | import * as plugins from '@r/platform/plugins';
|
335 | import Server from '@r/platform/Server';
|
336 |
|
337 | const server = Server({
|
338 | //...
|
339 | dispatchBeforeNavigation: async (ctx, dispatch, getState, utils) => {
|
340 | //...
|
341 | plugins.dispatchInitialShell(ctx, dispatch);
|
342 | }
|
343 | });
|
344 | ```
|
345 |
|
346 | This 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
|
348 | will be `false` during server request handling. Otherwise, it will be `true`.
|
349 | You can then check `state.shell` in your _handlers_ to determine whether or not
|
350 | to make API requests.
|
351 |
|
352 | You will also likely want to run `actions.activateClient` on the client side to
|
353 | ensure the navigation actions are re-fired client side, with `shell` set to
|
354 | `false`. (Otherwise, activateClient is unnecessary unless you need to re-run
|
355 | navigation handlers for some reason.)
|
356 |
|
357 | ```es6
|
358 | import * as actions from '@r/platform/actions';
|
359 | import Client from '@r/platform/Client';
|
360 |
|
361 | const client = Client({ /* ... */ });
|
362 | client.dispatch(actions.activateClient);
|
363 | ```
|
364 |
|
365 |
|
366 | ## Testing
|
367 | r/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 |
|
373 | 0. `reducers: object`: A dictionary of any reducers the store should contain
|
374 | 0. `middleware: array`: A list of middleware to be added to the store
|
375 | 0. `routes: array`: A routes list
|
376 |
|
377 | The `testFn` is called with a dictionary of helpers: `{ shallow, mount, render, expect, getStore, sinon }`.
|
378 |
|
379 | 0. `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)
|
380 | 0. `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)
|
381 | 0. `render: function`: Renders to static html. [more info](https://github.com/airbnb/enzyme/blob/master/docs/api/render.md)
|
382 | 0. `expect: function`: Assertion function.
|
383 | 0. `getStore: function`: Returns a store and a wrapper. Useful to testing components that depend on redux.
|
384 | 0. `sinon: object`: The entirety of sinon to help generate spies, stubs, and mocks. [more info](http://sinonjs.org/)
|
385 |
|
386 | ### Using createTest
|
387 | ```es6
|
388 | import createTest from '@r/platform/createTest';
|
389 | import Foo from './Foo';
|
390 |
|
391 | // testing with a connected component
|
392 | createTest(({ 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 | ```
|