├── .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 |
13 | 14 | 15 |
16 | 17 | 18 |
{schemaErrors.name}
19 |
20 |
21 | 22 | 23 |
{schemaErrors.age}
24 |
25 |
26 | 27 | 31 |
{schemaErrors.gender}
32 |
33 | 36 |
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 |
28 | 29 | 30 | 31 |
32 | 33 | 39 |
{schemaErrors.name}
40 |
41 |
42 | 43 | 50 |
{schemaErrors.age}
51 |
52 |
53 | 54 | 63 |
{schemaErrors.gender}
64 |
65 | 73 | 81 |
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 |
29 | 36 | 39 |
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 | --------------------------------------------------------------------------------