@r/platform
Version:
A set of tools to enable easy universal rendering and page navigation on a React + Redux stack
431 lines (342 loc) • 16.3 kB
Markdown
# r/platform
A set of tools to enable easy universal rendering and page navigation on a React + Redux stack.
## Change Log
#### v0.14.0
Removed the `postRouteServerMiddleware` configuration option. Middleware will now end with the route handler being fired.
## Installation
Currently, just use NPM.
```
npm install -S /platform
```
You also need to install its peer dependencies. For example:
```
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 /middleware@0.5.1
```
## Usage
### Server
```es6
// Server.es6.js
import Server from '@r/platform/Server';
const server = Server({
reducers: {}, // Reducers for the Redux store.
routes: [], // A list of lists that maps
// routes to handlers. For example:
//
// [
// ['/', Frontpage],
// ['/r/:subredditName', Subreddit],
// ]
template: (data) => { /* ... */ }, // a template function that returns a
// string (likely an HTML string).
port: 8888, // OPTIONAL. port for your server.
preRouteServerMiddleware: [], // OPTIONAL. Koa middleware to run
// before a route is handled
reduxMiddleware: [], // OPTIONAL. Additional Redux
// middleware. Middleware defined here
// will run before r/platform's
// middleware runs.
dispatchBeforeNavigation: async (koaCtx, dispatch, getState, utils) => {},
// OPTIONAL. Dispatch additional
// actions before the navigation
// fires.
getServerRouter: (router) => {} // OPTIONAL. Return the Koa router if
// needed.
});
// start the server
server();
```
### Client
```es6
// Client.es6.js
import Client from '@r/platform/Client';
const client = Client({
reducers: {}, // Reducers for the Redux store.
routes: [], // A list of lists that maps
// routes to handlers. For example:
//
// [
// ['/', Frontpage],
// ['/r/:subredditName', Subreddit],
// ]
appComponent: <div/>, // The React component that
// represents the app.
container: 'container', // OPTIONAL. Id of the DOM element
// the Client App will be rendered into.
dataVar: '___r', // OPTIONAL. A key on the 'window' object
// where the data will be written into.
modifyData: (data) => { /* ... */ }, // OPTIONAL. A function that mutates the
// data object before it is loaded
// into the client side store.
reduxMiddleware: [], // OPTIONAL. Additional Redux middleware.
// Middleware defined here will run
// before r/platform's middleware runs.
debug: false // OPTIONAL. Setting debug to true will
// cause redux actions to be logged
// in the console.
});
// run the client
client();
```
## Creating Routes
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.
All methods have access to the following properties:
0. `this.originalUrl`: the url that spawned this handler
0. `this.urlParams`: a dictionary of route defined params. e.g. if '/bar' matches '/:foo', urlParams would look like `{ foo: 'bar' }`.
0. `this.queryParams`: a dictionary of query params
0. `this.hashParams`: a dictionary of hash params
0. `this.bodyParams`: a dictionary of data that would appear in the request body
Each method is also called with the following arguments:
0. `dispatch`: a function used to dispatch Redux actions
0. `getState`: a function that (when called) returns a snapshot of state in the Redux store
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.
### Example
```es6
// routes.es6.js
import { BaseHandler, METHODS } from '@r/platform/router';
import * as actions from '@r/platform/actions';
import * as otherActions from './otherActions';
// Create a handler
class Frontpage extends BaseHandler {
async [METHODS.GET](dispatch, getState, { waitForState, waitForAction }) {
// pull out params if necessary
const { foo } = this.queryParams;
// dispatch certain actions synchronously
dispatch(otherActions.doSomething());
// if needed, wait on certain tasks to complete before dispatching further.
// on the Server side, the Server will wait for the entire function to
// complete before responding to the request with html.
const importantThing = await importantAsyncFunction();
// use the utility methods to wait on something in state
await waitForState(
state => state.foo === 'foo', // the condition
state => dispatch(/* something */) // the callback if condition is met
);
// further synchronous dispatches are possible. Thanks to es6/7, these won't
// fire until the previous asynchronous action has completed.
dispatch(/* something else */);
}
}
// Export the routes
export default [
['/', Frontpage],
];
```
## Keeping the Url in Sync
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.
```es6
import React from 'react';
import { UrlSync } from '@r/platform/components';
export default class App extends React.Component {
render() {
return (
<div>
{/* many components */}
<UrlSync/>
</div>
)
}
}
```
## Rendering pages
Often, you would like to render certain components based on the url state. To do so, you can use the `<UrlSwitch>` component:
```es6
import React from 'react';
import { UrlSwitch, Case, Page } from '@r/platform/url';
export default class Foo extends React.Component {
render() {
return (
<div>
<UrlSwitch>
<Case
// do something based on a url. this is the most generic way to use
// urlSwitch
url='/'
exec={ pageData => <div/> }
/>
<Page
// as a convenience, if a specific component needs to be rendered,
// use the <Page/> component instead. this takes a 'component'
// instead of a function. the props of the component are pageData
url='/r/:subredditName'
component={ FooComponent }
/>
<Case
url='*' // catch all
exec={ pageData => <div/> }
/>
</UrlSwitch>
</div>
);
}
}
```
## Easy routing
Sometimes, routing to a page might happen by clicking an anchor tag. Instead of manually connecting the anchor tag to a dispatch action, /platform exports a pre-connected anchor tag component:
```es6
import React from 'react';
import { Anchor } from '@r/platform/components';
export default class Foo extends React.Component {
render() {
return (
<div className='Foo'>
<Anchor
href='/foo?stuff=yeah'
className='Foo__anchor'
>
Click me!
</Anchor>
</div>
);
}
}
```
/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.
If those don't suite your needs, /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.
```es6
import React from 'react';
import { LinkHijacker } from '@r/platform/components';
export default class Foo extends React.Component {
render() {
return (
<div className='Foo'>
<LinkHijacker>
<div
className='Foo__content'
dangerouslySetInnerHTML={ { __html: this.props.htmlContent } }
/>
</LinkHijacker>
</div>
);
}
}
```
/platform exports a pre-connected form as well:
```es6
import React from 'react';
import { Form } from '@r/platform/components';
export default class Foo extends React.Component {
render() {
return (
<div className='Foo'>
<Form
action='/login'
className='Foo__form'
>
<input name='username'/>
<input name='password' type='password'/>
</Form>
</div>
);
}
}
```
## Additional Tools
There are a few additional goodies in r/platform
**Reducer**
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.
**Actions**
r/platform exposes a few Redux actions you can use to navigate through the app. They are:
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.
0. `gotoPageIndex(pageIndex)`: navigates to a particular page on the navigation stack.
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.
**Router**
r/platform doesn't use a traditional router. So instead, the router exports a Handler and some http verbs.
```es6
import { BaseHandler, METHODS } from '@r/platform/router';
console.log(METHODS); // {
// GET: 'get',
// POST: 'post',
// PUT: 'put',
// PATCH: 'patch',
// DELETE: 'delete',
// }
console.log(BaseHandler); // Described in the previous section on creating routes.
```
**merge**
r/platform includes a helpful utility method for "modifying" state while maintaining the immutability that Redux expects.
```es6
import merge from '@r/platform/merge';
import * as actions from '@r/platform/actions';
// reducer
export default function(state={}, action={}) {
switch(action.type) {
case actions.GOTO_PAGE_INDEX: {
const { pageIndex } = action.payload;
// `merge` lets you just deal with state diffs. just merge your
// diff with state and `merge` will preserve immutability.
return merge(state, {
currentPageIndex: pageIndex,
currentPage: state.history[pageIndex],
});
}
default: return state;
}
}
```
`merge` can also take options which let the method know how to deal with arrays and empty dictionaries.
`merge(state, diff, options={ emptyDict, array })`
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.
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.
**plugins**
You may wish to quickly render a shell of the page- such as a loading screen-
and make API requests on the client, rather than the server.
```es6
import * as plugins from '@r/platform/plugins';
import Server from '@r/platform/Server';
const server = Server({
//...
dispatchBeforeNavigation: async (ctx, dispatch, getState, utils) => {
//...
plugins.dispatchInitialShell(ctx, dispatch);
}
});
```
This will set state.shell to `true` or `false`. If you have a `nojs` cookie, a
`nojs` querystring, or your user-agent contains the word `bot`, state.shell
will be `false` during server request handling. Otherwise, it will be `true`.
You can then check `state.shell` in your _handlers_ to determine whether or not
to make API requests.
You will also likely want to run `actions.activateClient` on the client side to
ensure the navigation actions are re-fired client side, with `shell` set to
`false`. (Otherwise, activateClient is unnecessary unless you need to re-run
navigation handlers for some reason.)
```es6
import * as actions from '@r/platform/actions';
import Client from '@r/platform/Client';
const client = Client({ /* ... */ });
client.dispatch(actions.activateClient);
```
## Testing
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:
`createTest([storeOptions,] testFn)`
`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:
0. `reducers: object`: A dictionary of any reducers the store should contain
0. `middleware: array`: A list of middleware to be added to the store
0. `routes: array`: A routes list
The `testFn` is called with a dictionary of helpers: `{ shallow, mount, render, expect, getStore, sinon }`.
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)
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)
0. `render: function`: Renders to static html. [more info](https://github.com/airbnb/enzyme/blob/master/docs/api/render.md)
0. `expect: function`: Assertion function.
0. `getStore: function`: Returns a store and a wrapper. Useful to testing components that depend on redux.
0. `sinon: object`: The entirety of sinon to help generate spies, stubs, and mocks. [more info](http://sinonjs.org/)
### Using createTest
```es6
import createTest from '/platform/createTest';
import Foo from './Foo';
// testing with a connected component
createTest(({ mount, getStore, expect }) => {
describe('<Foo/>', () => {
it('should change state when clicked', () => {
const { store, StoreWrapper } = getStore();
const container = mount(
<StoreWrapper>
<Foo/>
</StoreWrapper>
);
container.find(Foo).simulate('click');
expect(store.getState().fooValue).to.equal('foo');
});
});
});
```