Skip to content

Commit

Permalink
Merge branch 'master' into heroku
Browse files Browse the repository at this point in the history
* master: (32 commits)
  fixed npm script problem and upgraded redux-form
  added form support with redux-form
  npm knows how to prefix binaries
  oops, left in debugger call
  improved syntax for including styles
  fixed #77
  added apology for #60 in the README
  linted
  url-loader fix for images
  switched back to file-loader to avoid error from requireServerImage() fixes #39
  Works on mac now
  Added better-npm-run to allow setting env-vars on all platforms
  fixed cat pic on server render
  minor grammar and js style tweak to README
  added url-loader to production webpack, too
  merged PR #67. lint-corrected merge. added obligatory cat pic. changed to use url-loader to encode small images.
  made pass lint
  incorporated PR #67 and added some comments to promise chaining in createTransitionHook()
  work on forms
  work on forms
  ...
  • Loading branch information
Erik Rasmussen committed Jul 31, 2015
2 parents afd43e2 + 2279d41 commit 22c652e
Show file tree
Hide file tree
Showing 23 changed files with 372 additions and 91 deletions.
1 change: 1 addition & 0 deletions .eslintignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
webpack/*
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ node_modules/
dist/
*.iml
webpack-stats.json
npm-debug.log
28 changes: 20 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ This is a starter boiler plate app I've put together using the following technol
* [React Hot Loader](https://github.com/gaearon/react-hot-loader)
* [Redux](https://github.com/gaearon/redux)'s futuristic [Flux](https://facebook.github.io/react/blog/2014/05/06/flux.html) implementation
* [Redux Dev Tools](https://github.com/gaearon/redux-devtools) for next generation DX (developer experience). Watch [Dan Abramov's talk](https://www.youtube.com/watch?v=xsSnOQynTHs).
* [redux-form](https://github.com/erikras/redux-form) to manage form state in Redux
* [style-loader](https://github.com/webpack/style-loader) and [sass-loader](https://github.com/jtangelder/sass-loader) to allow import of stylesheets

I cobbled this together from a wide variety of similar "starter" repositories. As I post this in June 2015, all of these libraries are right at the bleeding edge of web development. They may fall out of fashion as quickly as they have come into it, but I personally believe that this stack is the future of web development and will survive for several years. I'm building my new projects like this, and I recommend that you do, too.
Expand All @@ -33,6 +34,8 @@ npm install
npm run dev
```

Unfortunately, you'll have to run this twice, because the build depends on a file, `webpack-stats.json` generated by the build itself. [If you can solve this, please do.](https://github.com/erikras/react-redux-universal-hot-example/issues/60)

## Building and Running Production Server

```
Expand Down Expand Up @@ -81,21 +84,30 @@ This is where the meat of your server-side application goes. It doesn't have to

To understand how the data and action bindings get into the components – there's only one, `InfoBar`, in this example – I'm going to refer to you to the [Redux](https://github.com/gaearon/redux) library. The only innovation I've made is to package the component and its wrapper in the same js file. This is to encapsulate the fact that the component is bound to the `redux` actions and state. The component using `InfoBar` needn't know or care if `InfoBar` uses the `redux` data or not.

#### Images

Now it's possible to render the image both on client and server. Please refer to issue [#39](https://github.com/erikras/react-redux-universal-hot-example/issues/39) for more detail discussion, the usage would be like below (super easy):

```javascript
let logoImage = '';
if(__CLIENT__) {
logoImage = require('./logo.png');
} else {
logoImage = requireServerImage('./logo.png');
}
```

#### Styles

This project uses [local styles](https://medium.com/seek-ui-engineering/the-end-of-global-css-90d2a4a06284) using [css-loader](https://github.com/webpack/css-loader). The way it works is that you import your stylesheet at the top of the class with your React Component, and then you use the classnames returned from that import. Like so:

```javascript
const styles = (function getStyle() {
const stats = require('../../webpack-stats.json');
if (__CLIENT__) {
return require('./App.scss');
}
return stats.css.modules[path.join(__dirname, './App.scss')];
})();
const styles = __CLIENT__ ?
require('./App.scss') :
requireServerCss(require.resolve('./App.scss'));
```

That's a little ugly, I know, but what it allows is very powerful.
Then you set the `className` of your element ot match one of the CSS classes in your SCSS file, and you're good to go!

```jsx
<div className={styles.mySection}> ... </div>
Expand Down
38 changes: 32 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,36 @@
],
"main": "babel.server.js",
"scripts": {
"start": "NODE_PATH=\"./src\" NODE_ENV=\"production\" PORT=\"8080\" node ./babel.server",
"build": "node ./node_modules/webpack/bin/webpack.js --verbose --colors --display-error-details --config webpack/prod.config.js",
"lint": "node ./node_modules/eslint/bin/eslint.js -c .eslintrc src",
"start-dev": "NODE_PATH=\"./src\" NODE_ENV=\"development\" node ./babel.server",
"watch-client": "UV_THREADPOOL_SIZE=100 NODE_PATH=\"./src\" node webpack/webpack-dev-server.js",
"dev": "node ./node_modules/concurrently/src/main.js --kill-others \"npm run watch-client\" \"npm run start-dev\""
"start": "node ./node_modules/better-npm-run start",
"build": "webpack --verbose --colors --display-error-details --config webpack/prod.config.js",
"lint": "eslint -c .eslintrc src",
"start-dev": "node ./node_modules/better-npm-run start-dev",
"watch-client": "node ./node_modules/better-npm-run watch-client",
"dev": "concurrent --kill-others \"npm run watch-client\" \"npm run start-dev\""
},
"betterScripts": {
"start": {
"command": "node ./babel.server.js",
"env": {
"NODE_PATH": "./src",
"NODE_ENV": "production",
"PORT": 8080
}
},
"start-dev": {
"command": "node ./babel.server.js",
"env": {
"NODE_PATH": "./src",
"NODE_ENV": "development"
}
},
"watch-client": {
"command": "node webpack/webpack-dev-server.js",
"env": {
"UV_THREADPOOL_SIZE": 100,
"NODE_PATH": "./src"
}
}
},
"dependencies": {
"babel": "5.4.7",
Expand All @@ -48,6 +72,7 @@
"react-router": "v1.0.0-beta2",
"redux": "1.0.0-rc",
"redux-devtools": "0.1.2",
"redux-form": "^0.0.2",
"serialize-javascript": "^1.0.0",
"serve-favicon": "^2.3.0",
"serve-static": "^1.10.0",
Expand All @@ -60,6 +85,7 @@
"babel-eslint": "^3.1.18",
"babel-loader": "5.1.3",
"babel-runtime": "5.4.7",
"better-npm-run": "0.0.1",
"clean-webpack-plugin": "^0.1.3",
"concurrently": "0.1.1",
"css-loader": "^0.15.1",
Expand Down
11 changes: 2 additions & 9 deletions src/components/InfoBar.js
Original file line number Diff line number Diff line change
@@ -1,17 +1,10 @@
import path from 'path';
import React, {Component, PropTypes} from 'react';
import {bindActionCreators} from 'redux';
import {connect} from 'react-redux';
import * as infoActions from '../actions/infoActions';
import {relativeToSrc} from '../util';
import {requireServerCss} from '../util';

const styles = (function getStyle() {
const stats = require('../../webpack-stats.json');
if (__CLIENT__) {
return require('./InfoBar.scss');
}
return stats.css.modules[relativeToSrc(path.join(__dirname, './InfoBar.scss'))];
})();
const styles = __CLIENT__ ? require('./InfoBar.scss') : requireServerCss(require.resolve('./InfoBar.scss'));

class InfoBar extends Component {
static propTypes = {
Expand Down
3 changes: 3 additions & 0 deletions src/reducers/index.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import {createFormReducer} from 'redux-form';
import surveyValidation from '../validation/surveyValidation';
export info from './info';
export widgets from './widgets';
export auth from './auth';
export counter from './counter';
export const survey = createFormReducer('survey', ['name', 'email', 'occupation'], surveyValidation);
14 changes: 7 additions & 7 deletions src/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,13 +51,12 @@ app.use((req, res) => {
} else {
universalRouter(location, undefined, store)
.then(({component, transition, isRedirect}) => {

if (isRedirect) {
res.redirect(transition.redirectInfo.pathname);
return;
}
res.send('<!doctype html>\n' +
React.renderToString(<Html webpackStats={webpackStats} component={component} store={store}/>));
if (isRedirect) {
res.redirect(transition.redirectInfo.pathname);
return;
}
res.send('<!doctype html>\n' +
React.renderToString(<Html webpackStats={webpackStats} component={component} store={store}/>));
})
.catch((error) => {
console.error('ROUTER ERROR:', pretty.render(error));
Expand All @@ -74,6 +73,7 @@ if (config.port) {
api().then(() => {
console.info('==> ✅ Server is listening');
console.info('==> 🌎 %s running on port %s, API on port %s', config.app.name, config.port, config.apiPort);
console.info('----------\n==> 💻 Open http://localhost:%s in a browser to view the app.', config.port);
});
}
});
Expand Down
21 changes: 10 additions & 11 deletions src/universalRouter.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,20 @@ import Router from 'react-router';
import routes from './views/routes';
import { Provider } from 'react-redux';

const getFetchData = (component) => {
return component.fetchData || (component.DecoratedComponent && component.DecoratedComponent.fetchData);
const getFetchData = (component={}) => {
return component.DecoratedComponent ?
getFetchData(component.DecoratedComponent) :
component.fetchData;
};

export function createTransitionHook(store) {
return (nextState, transition, callback) => {
Promise.all(nextState.branch
.map(route => route.component)
.filter(component => {
return getFetchData(component);
})
.map(getFetchData)
.map(fetchData => {
return fetchData(store, nextState.params);
}))
const promises = nextState.branch
.map(route => route.component) // pull out individual route components
.filter((component) => getFetchData(component)) // only look at ones with a static fetchData()
.map(getFetchData) // pull out fetch data methods
.map(fetchData => fetchData(store, nextState.params)); // call fetch data methods and save promises
Promise.all(promises)
.then(() => {
callback(); // can't just pass callback to then() because callback assumes first param is error
}, (error) => {
Expand Down
40 changes: 36 additions & 4 deletions src/util.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,39 @@
import path from 'path';
const readStats = () => {
// don't cache the `webpack-stats.json` on dev so we read the file on each request.
// on production, use simple `require` to cache the file
const stats = require('../webpack-stats.json');
if (__DEVELOPMENT__) {
delete require.cache[require.resolve('../webpack-stats.json')];
}
return stats;
};

const pathToSrc = path.resolve(__dirname, '.');
export function requireServerCss(cssPath) {
if (__CLIENT__) {
throw new Error('image-resolver called on browser');
}
return readStats().css.modules[cssPath.slice(__dirname.length)];
}

export function requireServerImage(imagePath) {
if (!imagePath) {
return '';
}
if (__CLIENT__) {
throw new Error('server-side only resolver called on client');
}
const images = readStats().images;
if (!images) {
return '';
}

// Find the correct image
const regex = new RegExp(`${imagePath}$`);
const image = images.find(img => regex.test(img.original));

// Serve image.
if (image) return image.compiled;

export function relativeToSrc(value) {
return value.slice(pathToSrc.length);
// Serve a not-found asset maybe?
return '';
}
8 changes: 8 additions & 0 deletions src/validation/surveyValidation.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import {createValidator, required, maxLength, email} from './validation';

const surveyValidation = createValidator({
name: [required, maxLength(10)],
email: [required, email],
occupation: maxLength(20) // single rules don't have to in an array
});
export default surveyValidation;
45 changes: 45 additions & 0 deletions src/validation/validation.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
const isEmpty = value => value === undefined || value === null || value === '';
const join = (rules) => value => rules.map(rule => rule(value)).filter(error => !!error)[0 /* first error */];

export function email(value) {
// Let's not start a debate on email regex. This is just for an example app!
if (!isEmpty(value) && !/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,4}$/i.test(value)) {
return 'Invalid email address';
}
}

export function required(value) {
if (isEmpty(value)) {
return 'Required';
}
}

export function minLength(min) {
return value => {
if (!isEmpty(value) && value.length < min) {
return `Must be fewer than ${min} characters`;
}
}
}

export function maxLength(max) {
return value => {
if (!isEmpty(value) && value.length > max) {
return `Must be fewer than ${max} characters`;
}
}
}

export function createValidator(rules) {
return (data = {}) => {
const errors = {};
Object.keys(rules).forEach((key) => {
const rule = join([].concat(rules[key])); // concat enables both functions and arrays of functions
const error = rule(data[key]);
if (error) {
errors[key] = error;
}
});
return errors;
}
}
27 changes: 26 additions & 1 deletion src/views/About.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,20 @@
import React, {Component} from 'react';
import MiniInfoBar from '../components/MiniInfoBar';
import {requireServerImage} from '../util';

const kitten = __CLIENT__ ? require('./kitten.jpg') : requireServerImage('./kitten.jpg');

export default class About extends Component {
state = {
showKitten: false
}

handleToggleKitten() {
this.setState({showKitten: !this.state.showKitten});
}

render() {
const {showKitten} = this.state;
return (
<div>
<div className="container">
Expand All @@ -18,9 +30,22 @@ export default class About extends Component {
<h3>Mini Bar <span style={{color: '#aaa'}}>(not that kind)</span></h3>

<p>Hey! You found the mini info bar! The following component is display-only. Note that it shows the same
time as the info bar.</p>
time as the info bar.</p>

<MiniInfoBar/>

<h3>Images</h3>

<p>
Psst! Would you like to see a kitten?

<button className={'btn btn-' + (showKitten ? 'danger' : 'success')}
style={{marginLeft: 50}}
onClick={::this.handleToggleKitten}>
{showKitten ? 'No! Take it away!' : 'Yes! Please!'}</button>
</p>

{showKitten && <div><img src={kitten}/></div>}
</div>
</div>
);
Expand Down
12 changes: 3 additions & 9 deletions src/views/App.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import path from 'path';
import React, {Component, PropTypes} from 'react';
import {Link} from 'react-router';
import {bindActionCreators} from 'redux';
Expand All @@ -10,15 +9,9 @@ import * as authActions from '../actions/authActions';
import {load as loadAuth} from '../actions/authActions';
import InfoBar from '../components/InfoBar';
import {createTransitionHook} from '../universalRouter';
import {relativeToSrc} from '../util';
import {requireServerCss} from '../util';

const styles = (function getStyle() {
const stats = require('../../webpack-stats.json');
if (__CLIENT__) {
return require('./App.scss');
}
return stats.css.modules[relativeToSrc(path.resolve(__dirname, './App.scss'))];
})();
const styles = __CLIENT__ ? require('./App.scss') : requireServerCss(require.resolve('./App.scss'));

class App extends Component {
static propTypes = {
Expand Down Expand Up @@ -54,6 +47,7 @@ class App extends Component {

<ul className="nav navbar-nav">
<li><Link to="/widgets">Widgets</Link></li>
<li><Link to="/survey">Survey</Link></li>
<li><Link to="/about">About Us</Link></li>
<li><Link to="/redirect">Redirect to Home</Link></li>
{!user && <li><Link to="/login">Login</Link></li>}
Expand Down
Loading

0 comments on commit 22c652e

Please sign in to comment.