├── .babelrc
├── .eslintrc.js
├── .gitignore
├── LICENSE
├── README.md
├── api
└── index.js
├── gulpfile.babel.js
├── package.json
├── server
├── index.html
└── index.js
├── src
├── formDispatcherBase.js
├── formDispatcherBrowser.js
├── handlers.js
├── history.js
├── index.js
├── middlewares
│ └── fetch.js
├── pages
│ ├── AddCat.js
│ ├── Cat.js
│ └── Cats.js
├── queryParameterHandlerBrowser.js
├── redux
│ ├── cats.js
│ ├── forms.js
│ └── index.js
└── routes.js
└── webpack.config.js
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": ["es2015", "react"],
3 | "plugins": [
4 | "transform-async-to-generator",
5 | "transform-object-rest-spread"
6 | ]
7 | }
8 |
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | parser: 'babel-eslint',
3 | extends: 'airbnb',
4 | rules: {
5 | 'react/prop-types': [0],
6 | }
7 | };
8 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /dist
2 | /build
3 | /node_modules
4 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright (c) 2016, Jacob Parker
2 |
3 | Permission to use, copy, modify, and/or distribute this software for any
4 | purpose with or without fee is hereby granted, provided that the above
5 | copyright notice and this permission notice appear in all copies.
6 |
7 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
8 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
9 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
10 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
11 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
12 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
13 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
14 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # React Isomorphic Demo
2 |
3 | ### See [react-router-4 branch](https://github.com/jacobp100/react-isomophic-demo/tree/react-router-4) for React Router 4
4 |
5 | See blog posts for details on implementations.
6 |
7 | * [Post 1](https://medium.com/@jacobp/progressive-enhancement-techniques-for-react-part-1-7a551966e4bf#.4wjw0grw2)
8 | * [Post 2](https://medium.com/@jacobp/progressive-enhancement-techniques-for-react-part-2-5cb21bf308e5#.ugemu980s)
9 | * [Post 3](https://medium.com/@jacobp/progressive-enhancement-techniques-for-react-part-3-117e8d191b33#.nhrqqjxyu)
10 |
11 | Open an issue if you need something clarifying.
12 |
13 | ## Changes from Blog Post
14 |
15 | ### Form Handling
16 |
17 | In post 1, [we put a separate form handlers for action creators inside the reducers](https://medium.com/@jacobp100/see-readme-4029b6c93733#.pb9xfm8pv). The concept of splitting the main action creator from the form validation remains the same. However, [the form handling has now been moved to formDispatcher](https://github.com/jacobp100/react-isomophic-demo/commit/09009b73070aaf3f0adfb32f63600f24cfdf8114); and there is a new reducer for forms, which contains schema errors, submission errors, and whether a form is being submitted.
18 |
19 | This centralised the form handling, and made it so that every form would always get relevant errors and submission state without any extra work. I highly recommend you follow this new way if using the form techniques discussed.
20 |
21 | ## Running
22 |
23 | This comprises of a client, server, and a quick API for demonstrational purposes.
24 |
25 | To build the client, server, and api, run `gulp`. Gulp will put the client files in `/dist`, and the server and api files in `/build` (since dist is a public folder and we don't want to expose the server or api).
26 |
27 | When the server and api are built, they can be run with `node build/server` and `node build/api`, respectively.
28 |
29 | When the server is running, you can head to `localhost:8080` in your browser. Try running this project with and without JavaScript---in Safari, this can be toggled in the
30 | *Developer* menu!
31 |
--------------------------------------------------------------------------------
/api/index.js:
--------------------------------------------------------------------------------
1 | const Express = require('express');
2 | const bodyParser = require('body-parser');
3 | const cors = require('cors');
4 | const { filter, matches, uniqueId } = require('lodash/fp');
5 |
6 | const server = new Express();
7 | server.use(bodyParser.json());
8 | server.use(cors());
9 |
10 | const database = {
11 | cats: [
12 | { id: uniqueId(), name: 'Sprinkles', age: 8, gender: 'male' },
13 | { id: uniqueId(), name: 'Boots', age: 5, gender: 'male' },
14 | { id: uniqueId(), name: 'Waffles', age: 9, gender: 'female' },
15 | ],
16 | };
17 |
18 | server.get('/cats', (req, res) => {
19 | res.json(filter(matches(req.query), database.cats));
20 | });
21 |
22 | server.put('/cats', (req, res) => {
23 | const cat = Object.assign({}, req.body, { id: uniqueId() });
24 | database.cats.push(cat);
25 |
26 | res.json(cat);
27 | });
28 |
29 | server.delete('/cats/:id', (req, res) => {
30 | const id = req.params.id;
31 | const catIndex = database.cats.findIndex(catEntry => catEntry.id === id);
32 |
33 | if (catIndex === -1) {
34 | res.status(500).send(`Failed to find cat with id ${id}`);
35 | return;
36 | }
37 |
38 | database.cats.splice(catIndex, 1);
39 |
40 | res.json('');
41 | });
42 |
43 | server.post('/cats/:id', (req, res) => {
44 | const id = req.params.id;
45 | const cat = database.cats.find(catEntry => catEntry.id === id);
46 |
47 | if (!cat) {
48 | res.status(500).send(`Failed to find cat with id ${id}`);
49 | return;
50 | }
51 |
52 | Object.assign(cat, req.body);
53 | res.json(cat);
54 | });
55 |
56 | server.listen(8081);
57 |
--------------------------------------------------------------------------------
/gulpfile.babel.js:
--------------------------------------------------------------------------------
1 | import gulp from 'gulp';
2 | import { join } from 'path';
3 | import webpack from 'webpack';
4 |
5 | const commonConfig = {
6 | context: __dirname,
7 | devtool: 'source-map',
8 | module: {
9 | loaders: [
10 | { test: /\.js$/, exclude: /node_modules/, loader: 'babel' },
11 | { test: /\.json$/, loader: 'json' },
12 | ],
13 | },
14 | };
15 |
16 | const nodeCommonConfig = {
17 | ...commonConfig,
18 | node: {
19 | console: false,
20 | global: false,
21 | process: false,
22 | Buffer: false,
23 | __filename: false,
24 | __dirname: false,
25 | setImmediate: false,
26 | },
27 | };
28 |
29 | gulp.task('html', () => (
30 | gulp.src('server/index.html')
31 | .pipe(gulp.dest('build'))
32 | ));
33 |
34 | gulp.task('client', cb => {
35 | webpack({
36 | ...commonConfig,
37 | entry: './src/index',
38 | output: {
39 | path: join(__dirname, 'dist'),
40 | filename: 'client.js',
41 | library: 'demo',
42 | libraryTarget: 'umd',
43 | },
44 | plugins: [
45 | new webpack.DefinePlugin({
46 | 'process.env': {
47 | NODE_ENV: JSON.stringify('production'),
48 | },
49 | }),
50 | new webpack.optimize.DedupePlugin(),
51 | ],
52 | }, cb);
53 | });
54 |
55 | gulp.task('server', ['html'], cb => {
56 | webpack({
57 | ...nodeCommonConfig,
58 | entry: './server/index',
59 | target: 'node',
60 | output: {
61 | path: join(__dirname, 'build'),
62 | filename: 'server.js',
63 | library: 'demoServer',
64 | libraryTarget: 'commonjs2',
65 | },
66 | }, cb);
67 | });
68 |
69 | gulp.task('api', cb => {
70 | webpack({
71 | ...nodeCommonConfig,
72 | entry: './api/index',
73 | target: 'node',
74 | output: {
75 | path: join(__dirname, 'build'),
76 | filename: 'api.js',
77 | library: 'demoServer',
78 | libraryTarget: 'commonjs2',
79 | },
80 | }, cb);
81 | });
82 |
83 | gulp.task('default', ['server', 'client', 'api']);
84 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-isomophic-demo",
3 | "version": "1.0.0",
4 | "description": "",
5 | "main": "index.js",
6 | "scripts": {
7 | "test": "echo \"Error: no test specified\" && exit 1"
8 | },
9 | "repository": {
10 | "type": "git",
11 | "url": "git+https://github.com/jacobp100/react-isomophic-demo.git"
12 | },
13 | "keywords": [],
14 | "author": "",
15 | "license": "ISC",
16 | "bugs": {
17 | "url": "https://github.com/jacobp100/react-isomophic-demo/issues"
18 | },
19 | "homepage": "https://github.com/jacobp100/react-isomophic-demo#readme",
20 | "devDependencies": {
21 | "babel": "^6.5.2",
22 | "babel-eslint": "^6.1.2",
23 | "babel-loader": "^6.2.4",
24 | "babel-plugin-transform-async-to-generator": "^6.8.0",
25 | "babel-plugin-transform-object-rest-spread": "^6.8.0",
26 | "babel-preset-es2015": "^6.9.0",
27 | "babel-preset-react": "^6.11.1",
28 | "eslint": "^2.13.1",
29 | "eslint-config-airbnb": "^9.0.1",
30 | "eslint-plugin-import": "^1.10.2",
31 | "eslint-plugin-jsx-a11y": "^1.5.5",
32 | "eslint-plugin-react": "^5.2.2",
33 | "gulp": "^3.9.1",
34 | "json-loader": "^0.5.4",
35 | "webpack": "^1.12.15",
36 | "webpack-dev-server": "^1.14.1"
37 | },
38 | "dependencies": {
39 | "babel-regenerator-runtime": "^6.5.0",
40 | "body-parser": "^1.15.2",
41 | "cors": "^2.7.1",
42 | "express": "^4.14.0",
43 | "form-serialize": "^0.7.1",
44 | "lodash": "^4.13.1",
45 | "react": "^15.2.1",
46 | "react-dom": "^15.2.1",
47 | "react-redux": "^4.4.5",
48 | "react-router": "^2.5.2",
49 | "redux": "^3.5.2",
50 | "redux-enqueue": "^1.0.0",
51 | "redux-thunk": "^2.1.0",
52 | "yargs": "^4.8.0",
53 | "yup": "^0.19.0"
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/server/index.html:
--------------------------------------------------------------------------------
1 |
2 |
Cats
3 |
4 |
5 | <%= markup %>
6 |
10 |
11 |
--------------------------------------------------------------------------------
/server/index.js:
--------------------------------------------------------------------------------
1 | /* eslint no-param-reassign: [0], no-console: [0] */
2 |
3 | import 'babel-regenerator-runtime';
4 |
5 | import { template, map, flow, compact } from 'lodash/fp';
6 | import React from 'react';
7 | import { renderToString } from 'react-dom/server';
8 | import Express from 'express';
9 | import bodyParser from 'body-parser';
10 | import { createStore, applyMiddleware } from 'redux';
11 | import { Provider } from 'react-redux';
12 | import thunk from 'redux-thunk';
13 | import { RouterContext, match } from 'react-router';
14 | import fetch from 'node-fetch';
15 | import fetchMiddleware from '../src/middlewares/fetch';
16 | import routes from '../src/routes';
17 | import { reducers } from '../src/redux';
18 | import formDispatcherBase from '../src/formDispatcherBase';
19 |
20 | import { readFileSync } from 'fs';
21 | import { join } from 'path';
22 |
23 |
24 | const config = {
25 | clientEndpoint: 'http://localhost:8081',
26 | serverEndpoint: 'http://localhost:8081',
27 | port: 8080,
28 | };
29 |
30 |
31 | const index = readFileSync(join(__dirname, './index.html'));
32 | const renderTemplate = template(index);
33 |
34 | const server = new Express();
35 | server.use(bodyParser.urlencoded({ extended: true }));
36 |
37 |
38 | // STATIC FILES
39 | server.use('/dist', Express.static(join(__dirname, '../dist')));
40 |
41 |
42 | // SETUP STORE
43 | server.all('*', (req, res, next) => {
44 | const middlewares = applyMiddleware(
45 | thunk,
46 | fetchMiddleware(config.serverEndpoint, fetch)
47 | );
48 | req.store = createStore(reducers, middlewares);
49 |
50 | next();
51 | });
52 |
53 |
54 | // FORM HANDLERS
55 | server.post('*', async (req, res, next) => {
56 | try {
57 | const inputParams = req.body;
58 | const { redirect } = await req.store.dispatch(formDispatcherBase(inputParams));
59 |
60 | if (redirect) {
61 | res.redirect(redirect);
62 | } else {
63 | next();
64 | }
65 | } catch (e) {
66 | console.log(e);
67 | next();
68 | }
69 | });
70 |
71 |
72 | // RENDER PAGE
73 | server.all('*', (req, res) => {
74 | const { store } = req;
75 |
76 | match({ routes, location: req.url }, (error, redirectLocation, renderProps) => {
77 | if (redirectLocation) {
78 | res.redirect(301, redirectLocation.pathname + redirectLocation.search);
79 | } else if (error) {
80 | res.status(500).send(error.message);
81 | } else if (!renderProps) {
82 | res.status(404).send('Not found');
83 | } else {
84 | const { location, params } = renderProps;
85 | const { dispatch, getState } = store;
86 |
87 | const dataFetchingRequirements = flow(
88 | map('WrappedComponent.fetchData'),
89 | compact,
90 | map(fetchData => fetchData({ location, params, dispatch }))
91 | )(renderProps.components);
92 |
93 | Promise.all(dataFetchingRequirements)
94 | .catch(() => {}) // Ignore errors from data fetching
95 | .then(() => {
96 | const reduxState = getState();
97 | const markup = renderToString(
98 |
99 |
100 |
101 | );
102 |
103 | return { markup, reduxState };
104 | })
105 | .catch(e => { // But not errors from rendering to a string
106 | console.error(`Failed to serve ${req.url}`);
107 | console.error(e);
108 |
109 | const markup = renderToString();
110 | return { markup, reduxState: null };
111 | })
112 | .then(({ markup, reduxState }) => {
113 | res.send(renderTemplate({
114 | apiEndpoint: config.clientEndpoint,
115 | markup,
116 | reduxState,
117 | }));
118 | }, () => {
119 | res.status(500).send('Failed to load page');
120 | });
121 | }
122 | });
123 | });
124 |
125 | server.listen(config.port, err => {
126 | if (err) {
127 | console.error(err);
128 | } else {
129 | console.log('Server started');
130 | }
131 | });
132 |
--------------------------------------------------------------------------------
/src/formDispatcherBase.js:
--------------------------------------------------------------------------------
1 | import handlers from './handlers';
2 | import { setSchemaErrors, setSubmissionError, setFormIsSubmitting } from './redux/forms';
3 |
4 |
5 | const yupErrors = e => e.inner.reduce((accum, { path, message }) => {
6 | accum[path] = message; // eslint-disable-line
7 | return accum;
8 | }, {});
9 |
10 | const validateForm = async (inputParams, schema) => {
11 | try {
12 | const params = await schema.validate(inputParams, {
13 | abortEarly: false,
14 | stripUnknown: true,
15 | });
16 | return params;
17 | } catch (e) {
18 | const schemaErrors = yupErrors(e);
19 | throw schemaErrors;
20 | }
21 | };
22 |
23 | export default inputParams => async dispatch => {
24 | let { form, ...params } = inputParams; // eslint-disable-line
25 | const { handler, ref = handler } = form;
26 |
27 | if (!(handler in handlers)) throw new Error(`No handler found for ${handler}`);
28 |
29 | let { actionCreator, schema, redirect = null } = handlers[handler]; // eslint-disable-line
30 |
31 | try {
32 | params = await validateForm(params, schema);
33 | } catch (schemaErrors) {
34 | dispatch(setSchemaErrors(ref, schemaErrors));
35 | return { redirect: null };
36 | }
37 |
38 | try {
39 | dispatch(setFormIsSubmitting(ref, true));
40 |
41 | const action = actionCreator(params);
42 | await dispatch(action);
43 |
44 | if (typeof redirect === 'function') redirect = redirect(inputParams);
45 |
46 | return { redirect };
47 | } catch (e) {
48 | const sumbissionError = e.message;
49 | dispatch(setSubmissionError(ref, sumbissionError));
50 | return { redirect: null };
51 | } finally {
52 | dispatch(setFormIsSubmitting(ref, false));
53 | }
54 | };
55 |
--------------------------------------------------------------------------------
/src/formDispatcherBrowser.js:
--------------------------------------------------------------------------------
1 | import serialize from 'form-serialize';
2 | import history from './history';
3 | import formDispatcherBase from './formDispatcherBase';
4 |
5 |
6 | export const getFormData = event => {
7 | if ('preventDefault' in event) event.preventDefault(); // Sometimes we fake events
8 |
9 | const { currentTarget } = event;
10 | const isChildOfForm = 'form' in currentTarget;
11 | const form = isChildOfForm ? currentTarget.form : currentTarget;
12 | const actionParams = serialize(form, { hash: true, empty: true });
13 |
14 | if (isChildOfForm && currentTarget.tagName === 'BUTTON' && currentTarget.name) {
15 | actionParams[currentTarget.name] = currentTarget.value;
16 | }
17 |
18 | return actionParams;
19 | };
20 |
21 | export const createFormDispatcher = dispatch => async event => {
22 | const inputParams = getFormData(event);
23 |
24 | try {
25 | const { redirect } = await dispatch(formDispatcherBase(inputParams));
26 | if (redirect) history.push(redirect);
27 | } catch (e) {
28 | return;
29 | }
30 | };
31 |
--------------------------------------------------------------------------------
/src/handlers.js:
--------------------------------------------------------------------------------
1 | import yup from 'yup';
2 | import { addCat, removeCat, updateCat } from './redux/cats';
3 |
4 |
5 | export default {
6 | 'add-cat': {
7 | actionCreator: addCat,
8 | schema: yup.object().shape({
9 | name: yup.string().required('You must provide a name'),
10 | age: yup.number().typeError('Must be a number'),
11 | gender: yup.string().oneOf(['male', 'female'], 'Must select a valid gender'),
12 | }),
13 | redirect: '/',
14 | },
15 | 'update-remove-cat': {
16 | actionCreator: ({ id, action, ...params }) => (
17 | action === 'remove'
18 | ? removeCat(id)
19 | : updateCat(id, params)
20 | ),
21 | schema: yup.object().shape({
22 | id: yup.string(),
23 | action: yup.string(),
24 | name: yup.string().required('You must provide a name'),
25 | age: yup.number().typeError('Must be a number'),
26 | gender: yup.string().oneOf(['male', 'female'], 'Must select a valid gender'),
27 | }),
28 | redirect: params => (params.action === 'remove' ? '/' : null),
29 | },
30 | };
31 |
--------------------------------------------------------------------------------
/src/history.js:
--------------------------------------------------------------------------------
1 | import { useRouterHistory } from 'react-router';
2 | import createBrowserHistory from 'history/lib/createBrowserHistory';
3 | import qs from 'qs';
4 |
5 | const history = global.navigator
6 | ? useRouterHistory(createBrowserHistory)({
7 | stringifyQuery: qs.stringify,
8 | parseQueryString: qs.parse,
9 | })
10 | : null;
11 |
12 | export default history;
13 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import 'babel-regenerator-runtime';
2 |
3 | import React from 'react';
4 | import { render } from 'react-dom';
5 | import { Router } from 'react-router';
6 | import { createStore, applyMiddleware } from 'redux';
7 | import { Provider } from 'react-redux';
8 | import thunk from 'redux-thunk';
9 | import fetchMiddleware from './middlewares/fetch';
10 | import history from './history';
11 | import routes from './routes';
12 | import { reducers } from './redux';
13 |
14 |
15 | const initialState = global.__REDUX_STATE__ || {}; // eslint-disable-line
16 | const endpoint = global.__API_ENDPOINT__ || ''; // eslint-disable-line
17 | const middlewares = applyMiddleware(
18 | thunk,
19 | fetchMiddleware(endpoint, global.fetch)
20 | );
21 |
22 | const store = createStore(reducers, initialState, middlewares);
23 |
24 | render((
25 |
26 |
27 |
28 | ), document.getElementById('cats'));
29 |
--------------------------------------------------------------------------------
/src/middlewares/fetch.js:
--------------------------------------------------------------------------------
1 | /* eslint no-console: [0] */
2 | const FETCH = '@@middleware/fetch/FETCH';
3 |
4 |
5 | const headers = {
6 | 'Content-Type': 'application/json',
7 | };
8 |
9 | export default (baseUrl, fetchImplementation = global.fetch) => {
10 | const doFetch = async (method, url, data) => {
11 | const params = { method, headers };
12 | if (data) params.body = JSON.stringify(data);
13 |
14 | const response = await fetchImplementation(url, params);
15 | return await response.json();
16 | };
17 |
18 | return () => next => action => (
19 | (action.type !== FETCH)
20 | ? next(action)
21 | : doFetch(action.method, `${baseUrl}${action.url}`, action.data)
22 | );
23 | };
24 |
25 | export const fetchJson = (method, url, data) => ({
26 | type: FETCH,
27 | method,
28 | url,
29 | data,
30 | });
31 |
--------------------------------------------------------------------------------
/src/pages/AddCat.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { connect } from 'react-redux';
3 | import { Link } from 'react-router';
4 | import { createFormDispatcher } from '../formDispatcherBrowser';
5 |
6 |
7 | const getFormRef = () => 'add-cat';
8 |
9 | const AddCat = ({ schemaErrors, submissionError, isSubmitting, handleCatAddition }) => (
10 |
11 |
Add Cat
12 |
37 | {submissionError &&
{submissionError}
}
38 |
Back
39 |
40 | );
41 |
42 | export default connect(
43 | state => ({
44 | schemaErrors: state.forms.schemaErrors[getFormRef()] || {},
45 | submissionError: state.forms.submissionError[getFormRef()] || '',
46 | isSubmitting: state.forms.isSubmitting[getFormRef()] || false,
47 | }),
48 | dispatch => ({
49 | handleCatAddition: createFormDispatcher(dispatch),
50 | })
51 | )(AddCat);
52 |
--------------------------------------------------------------------------------
/src/pages/Cat.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import { connect } from 'react-redux';
3 | import { Link } from 'react-router';
4 | import { getCats } from '../redux/cats';
5 | import { createFormDispatcher } from '../formDispatcherBrowser';
6 |
7 |
8 | const getFormRef = id => `update-remove-cat:${id}`;
9 |
10 | class Cats extends Component {
11 | static fetchData({ dispatch }) {
12 | return dispatch(getCats());
13 | }
14 |
15 | componentDidMount() {
16 | Cats.fetchData(this.props);
17 | }
18 |
19 | render() {
20 | const { cat, schemaErrors, submissionError, isSubmitting, handleCatUpdateRemove } = this.props;
21 |
22 | if (!cat) return ;
23 |
24 | return (
25 |
26 |
{cat.name}
27 |
82 | {submissionError &&
{submissionError}
}
83 |
Back
84 |
85 | );
86 | }
87 | }
88 |
89 | export default connect(
90 | (state, { params }) => ({
91 | cat: state.cats.cats[params.id],
92 | schemaErrors: state.forms.schemaErrors[getFormRef(params.id)] || {},
93 | submissionError: state.forms.submissionError[getFormRef(params.id)] || '',
94 | isSubmitting: state.forms.isSubmitting[getFormRef(params.id)] || false,
95 | }),
96 | dispatch => ({
97 | handleCatUpdateRemove: createFormDispatcher(dispatch),
98 | dispatch,
99 | })
100 | )(Cats);
101 |
--------------------------------------------------------------------------------
/src/pages/Cats.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import { connect } from 'react-redux';
3 | import { Link } from 'react-router';
4 | import { map } from 'lodash/fp';
5 | import { setQueryParams, getCats } from '../redux/cats';
6 | import createQueryParameterHandler from '../queryParameterHandlerBrowser';
7 |
8 | class Cats extends Component {
9 | static fetchData({ location, dispatch }) {
10 | return dispatch(setQueryParams(location.query))
11 | .then(() => dispatch(getCats()));
12 | }
13 |
14 | componentDidMount() {
15 | Cats.fetchData(this.props);
16 | }
17 |
18 | componentDidUpdate() {
19 | Cats.fetchData(this.props);
20 | }
21 |
22 | render() {
23 | const { catIds, cats, genderFilter, setFilter } = this.props;
24 |
25 | return (
26 |
27 |
Cats
28 |
40 |
41 | {map(id => (
42 |
43 |
44 | {cats[id].name}
45 |
46 |
47 | ), catIds)}
48 |
49 |
50 | Add Cat
51 |
52 |
53 | );
54 | }
55 | }
56 |
57 | export default connect(
58 | state => ({
59 | catIds: state.cats.catIds,
60 | cats: state.cats.cats,
61 | genderFilter: state.cats.genderFilter,
62 | }),
63 | (dispatch, { location }) => ({
64 | setFilter: createQueryParameterHandler(location),
65 | dispatch,
66 | })
67 | )(Cats);
68 |
--------------------------------------------------------------------------------
/src/queryParameterHandlerBrowser.js:
--------------------------------------------------------------------------------
1 | import { __, update, assign } from 'lodash/fp';
2 | import { getFormData } from './formDispatcherBrowser';
3 | import history from './history';
4 |
5 | // Submitting a form GET to the server sets the query parameters to the form data
6 | export default location => e => {
7 | const formQuery = getFormData(e);
8 | const locationWithFormQuery = update('query', assign(__, formQuery), location);
9 | history.replace(locationWithFormQuery);
10 | };
11 |
--------------------------------------------------------------------------------
/src/redux/cats.js:
--------------------------------------------------------------------------------
1 | import {
2 | __, flow, set, update, map, reduce, assign, reject, equals, curry, union, flip, compact,
3 | } from 'lodash/fp';
4 | import { fetchJson } from '../middlewares/fetch';
5 |
6 |
7 | const defaultState = {
8 | catIds: [],
9 | cats: {},
10 | genderFilter: '',
11 | didSetCats: false,
12 | };
13 |
14 | export const SET_CATS = 'cats/SET_CATS';
15 | export const UPDATE_CAT = 'cats/UPDATE_CAT';
16 | export const REMOVE_CAT = 'cats/REMOVE_CAT';
17 | export const SET_FILTER = 'cats/SET_FILTER';
18 | export const SET_CAT_FORM_ERRORS = 'cats/SET_CAT_FORM_ERRORS';
19 |
20 |
21 | const updateCatInState = curry((cat, state) => update(['cats', cat.id], assign(__, cat), state));
22 |
23 | export default (state = defaultState, action) => {
24 | switch (action.type) {
25 | case SET_CATS:
26 | return flow(
27 | set('catIds', map('id', action.cats)),
28 | reduce(flip(updateCatInState), __, action.cats),
29 | set('didSetCats', true)
30 | )(state);
31 | case UPDATE_CAT: // eslint-disable-line
32 | return flow(
33 | update('catIds', union([action.cat.id])),
34 | updateCatInState(action.cat)
35 | )(state);
36 | case REMOVE_CAT:
37 | return update('catIds', reject(equals(action.id)), state);
38 | case SET_FILTER:
39 | return action.genderFilter === state.genderFilter
40 | ? state
41 | : flow(
42 | set('genderFilter', action.genderFilter),
43 | set('didSetCats', false)
44 | )(state);
45 | case SET_CAT_FORM_ERRORS:
46 | return set(['formErrorsPerCat', action.id], action.errors, state);
47 | default:
48 | return state;
49 | }
50 | };
51 |
52 | export const getCats = () => async (dispatch, getState) => {
53 | const { didSetCats, genderFilter } = getState().cats || {};
54 |
55 | if (didSetCats) return;
56 |
57 | const query = compact([
58 | genderFilter && `gender=${genderFilter}`,
59 | ]).join('&');
60 | const url = compact(['/cats', query]).join('?');
61 | const cats = await dispatch(fetchJson('GET', url));
62 | dispatch({ type: SET_CATS, cats });
63 | };
64 |
65 | export const updateCat = (id, params) => async dispatch => {
66 | try {
67 | const cat = await dispatch(fetchJson('POST', `/cats/${id}`, params));
68 | dispatch({ type: UPDATE_CAT, cat });
69 | } catch (e) {
70 | throw new Error('Failed to update cat');
71 | }
72 | };
73 |
74 | export const addCat = params => async dispatch => {
75 | try {
76 | const cat = await dispatch(fetchJson('PUT', '/cats', params));
77 | dispatch({ type: UPDATE_CAT, cat });
78 | } catch (e) {
79 | throw new Error('Failed to add cat');
80 | }
81 | };
82 |
83 | export const removeCat = id => async dispatch => {
84 | try {
85 | await dispatch(fetchJson('DELETE', `/cats/${id}`));
86 | dispatch({ type: REMOVE_CAT, id });
87 | } catch (e) {
88 | throw new Error('Failed to remove cat');
89 | }
90 | };
91 |
92 | export const setQueryParams = inputParams => async dispatch => {
93 | // Async because you might use validation
94 | dispatch({ type: SET_FILTER, genderFilter: inputParams.gender });
95 | };
96 |
--------------------------------------------------------------------------------
/src/redux/forms.js:
--------------------------------------------------------------------------------
1 | import { set } from 'lodash/fp';
2 |
3 | const defaultState = {
4 | schemaErrors: {},
5 | submissionError: {},
6 | isSubmitting: {},
7 | };
8 |
9 | export const SET_FORM_SCHEMA_ERRORS = 'forms/SET_FORM_SCHEMA_ERRORS';
10 | export const SET_FORM_SUBMISSION_ERROR = 'forms/SET_FORM_SUBMISSION_ERROR';
11 | export const SET_FORM_IS_SUBMITTING = 'forms/SET_FORM_IS_SUBMITTING';
12 |
13 | export default (state = defaultState, action) => {
14 | switch (action.type) {
15 | case SET_FORM_SCHEMA_ERRORS:
16 | return set(['schemaErrors', action.formId], action.schemaErrors, state);
17 | case SET_FORM_SUBMISSION_ERROR:
18 | return set(['submissionError', action.formId], action.submissionError, state);
19 | case SET_FORM_IS_SUBMITTING:
20 | return set(['isSubmitting', action.formId], action.isSubmitting, state);
21 | default:
22 | return state;
23 | }
24 | };
25 |
26 | export const setSchemaErrors = (formId, schemaErrors) =>
27 | ({ type: SET_FORM_SCHEMA_ERRORS, formId, schemaErrors });
28 | export const setSubmissionError = (formId, submissionError) =>
29 | ({ type: SET_FORM_SUBMISSION_ERROR, formId, submissionError });
30 | export const setFormIsSubmitting = (formId, isSubmitting) =>
31 | ({ type: SET_FORM_IS_SUBMITTING, formId, isSubmitting });
32 |
--------------------------------------------------------------------------------
/src/redux/index.js:
--------------------------------------------------------------------------------
1 | import { combineReducers } from 'redux';
2 | import cats from './cats';
3 | import forms from './forms';
4 |
5 | export const reducers = combineReducers({
6 | cats,
7 | forms,
8 | });
9 |
--------------------------------------------------------------------------------
/src/routes.js:
--------------------------------------------------------------------------------
1 | import Cats from './pages/Cats';
2 | import AddCat from './pages/AddCat';
3 | import Cat from './pages/Cat';
4 |
5 | export default {
6 | path: '/',
7 | indexRoute: {
8 | component: Cats,
9 | },
10 | childRoutes: [
11 | {
12 | path: 'cats/add',
13 | component: AddCat,
14 | },
15 | {
16 | path: 'cats/:id',
17 | component: Cat,
18 | },
19 | ],
20 | };
21 |
--------------------------------------------------------------------------------
/webpack.config.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 |
3 | module.exports = {
4 | context: __dirname,
5 | entry: './src/index',
6 | devtool: 'source-map',
7 | output: {
8 | path: path.join(__dirname, '/dist'),
9 | filename: 'cats.js',
10 | library: 'cats',
11 | libraryTarget: 'umd',
12 | publicPath: '/dist/',
13 | },
14 | module: {
15 | loaders: [
16 | { test: /\.js$/, exclude: /node_modules/, loader: 'babel' },
17 | ],
18 | },
19 | devServer: {
20 | historyApiFallback: true,
21 | host: '0.0.0.0',
22 | },
23 | };
24 |
--------------------------------------------------------------------------------