1 | # r/platform
|
2 | A set of tools to enable easy universal rendering and page navigation on a React + Redux stack.
|
3 |
|
4 | ## Change Log
|
5 | #### v0.14.0
|
6 | Removed the `postRouteServerMiddleware` configuration option. Middleware will now end with the route handler being fired.
|
7 |
|
8 | ## Installation
|
9 | Currently, just use NPM.
|
10 | ```
|
11 | npm install -S @r/platform
|
12 | ```
|
13 |
|
14 | You also need to install its peer dependencies. For example:
|
15 | ```
|
16 | 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
|
17 | ```
|
18 |
|
19 | ## Usage
|
20 | ### Server
|
21 | ```es6
|
22 | // Server.es6.js
|
23 | import Server from '@r/platform/Server';
|
24 |
|
25 | const 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
|
59 | server();
|
60 | ```
|
61 |
|
62 | ### Client
|
63 | ```es6
|
64 | // Client.es6.js
|
65 | import Client from '@r/platform/Client';
|
66 |
|
67 | const 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
|
101 | client();
|
102 | ```
|
103 |
|
104 | ## Creating Routes
|
105 | 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.
|
106 |
|
107 | All methods have access to the following properties:
|
108 |
|
109 | 0. `this.originalUrl`: the url that spawned this handler
|
110 | 0. `this.urlParams`: a dictionary of route defined params. e.g. if '/bar' matches '/:foo', urlParams would look like `{ foo: 'bar' }`.
|
111 | 0. `this.queryParams`: a dictionary of query params
|
112 | 0. `this.hashParams`: a dictionary of hash params
|
113 | 0. `this.bodyParams`: a dictionary of data that would appear in the request body
|
114 |
|
115 | Each method is also called with the following arguments:
|
116 |
|
117 | 0. `dispatch`: a function used to dispatch Redux actions
|
118 | 0. `getState`: a function that (when called) returns a snapshot of state in the Redux store
|
119 | 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.
|
120 |
|
121 | ### Example
|
122 | ```es6
|
123 | // routes.es6.js
|
124 | import { BaseHandler, METHODS } from '@r/platform/router';
|
125 | import * as actions from '@r/platform/actions';
|
126 | import * as otherActions from './otherActions';
|
127 |
|
128 | // Create a handler
|
129 | class 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
|
155 | export default [
|
156 | ['/', Frontpage],
|
157 | ];
|
158 | ```
|
159 |
|
160 | ## Keeping the Url in Sync
|
161 | 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.
|
162 |
|
163 | ```es6
|
164 | import React from 'react';
|
165 | import { UrlSync } from '@r/platform/components';
|
166 |
|
167 | export 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
|
180 | Often, you would like to render certain components based on the url state. To do so, you can use the `<UrlSwitch>` component:
|
181 |
|
182 | ```es6
|
183 | import React from 'react';
|
184 | import { UrlSwitch, Case, Page } from '@r/platform/url';
|
185 |
|
186 | export 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
|
217 | 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:
|
218 |
|
219 | ```es6
|
220 | import React from 'react';
|
221 | import { Anchor } from '@r/platform/components';
|
222 |
|
223 | export 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 |
|
241 | If 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
|
244 | import React from 'react';
|
245 | import { LinkHijacker } from '@r/platform/components';
|
246 |
|
247 | export 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
|
266 | import React from 'react';
|
267 | import { Form } from '@r/platform/components';
|
268 |
|
269 | export 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
|
287 | There are a few additional goodies in r/platform
|
288 |
|
289 | **Reducer**
|
290 |
|
291 | 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.
|
292 |
|
293 | **Actions**
|
294 |
|
295 | r/platform exposes a few Redux actions you can use to navigate through the app. They are:
|
296 |
|
297 | 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.
|
298 | 0. `gotoPageIndex(pageIndex)`: navigates to a particular page on the navigation stack.
|
299 | 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.
|
300 |
|
301 | **Router**
|
302 |
|
303 | r/platform doesn't use a traditional router. So instead, the router exports a Handler and some http verbs.
|
304 | ```es6
|
305 | import { BaseHandler, METHODS } from '@r/platform/router';
|
306 |
|
307 | console.log(METHODS); // {
|
308 | // GET: 'get',
|
309 | // POST: 'post',
|
310 | // PUT: 'put',
|
311 | // PATCH: 'patch',
|
312 | // DELETE: 'delete',
|
313 | // }
|
314 |
|
315 | console.log(BaseHandler); // Described in the previous section on creating routes.
|
316 | ```
|
317 |
|
318 | **merge**
|
319 |
|
320 | r/platform includes a helpful utility method for "modifying" state while maintaining the immutability that Redux expects.
|
321 | ```es6
|
322 | import merge from '@r/platform/merge';
|
323 | import * as actions from '@r/platform/actions';
|
324 |
|
325 | // reducer
|
326 | export 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 |
|
347 | 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.
|
348 |
|
349 | 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.
|
350 |
|
351 | **plugins**
|
352 |
|
353 | You may wish to quickly render a shell of the page- such as a loading screen-
|
354 | and make API requests on the client, rather than the server.
|
355 |
|
356 | ```es6
|
357 | import * as plugins from '@r/platform/plugins';
|
358 | import Server from '@r/platform/Server';
|
359 |
|
360 | const server = Server({
|
361 | //...
|
362 | dispatchBeforeNavigation: async (ctx, dispatch, getState, utils) => {
|
363 | //...
|
364 | plugins.dispatchInitialShell(ctx, dispatch);
|
365 | }
|
366 | });
|
367 | ```
|
368 |
|
369 | This 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
|
371 | will be `false` during server request handling. Otherwise, it will be `true`.
|
372 | You can then check `state.shell` in your _handlers_ to determine whether or not
|
373 | to make API requests.
|
374 |
|
375 | You will also likely want to run `actions.activateClient` on the client side to
|
376 | ensure the navigation actions are re-fired client side, with `shell` set to
|
377 | `false`. (Otherwise, activateClient is unnecessary unless you need to re-run
|
378 | navigation handlers for some reason.)
|
379 |
|
380 | ```es6
|
381 | import * as actions from '@r/platform/actions';
|
382 | import Client from '@r/platform/Client';
|
383 |
|
384 | const client = Client({ /* ... */ });
|
385 | client.dispatch(actions.activateClient);
|
386 | ```
|
387 |
|
388 |
|
389 | ## Testing
|
390 | 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:
|
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 |
|
396 | 0. `reducers: object`: A dictionary of any reducers the store should contain
|
397 | 0. `middleware: array`: A list of middleware to be added to the store
|
398 | 0. `routes: array`: A routes list
|
399 |
|
400 | The `testFn` is called with a dictionary of helpers: `{ shallow, mount, render, expect, getStore, sinon }`.
|
401 |
|
402 | 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)
|
403 | 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)
|
404 | 0. `render: function`: Renders to static html. [more info](https://github.com/airbnb/enzyme/blob/master/docs/api/render.md)
|
405 | 0. `expect: function`: Assertion function.
|
406 | 0. `getStore: function`: Returns a store and a wrapper. Useful to testing components that depend on redux.
|
407 | 0. `sinon: object`: The entirety of sinon to help generate spies, stubs, and mocks. [more info](http://sinonjs.org/)
|
408 |
|
409 | ### Using createTest
|
410 | ```es6
|
411 | import createTest from '@r/platform/createTest';
|
412 | import Foo from './Foo';
|
413 |
|
414 | // testing with a connected component
|
415 | createTest(({ 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 | ```
|