├── .env ├── .eslintignore ├── api ├── actions │ ├── widget │ │ ├── index.js │ │ ├── update.js │ │ └── load.js │ ├── loadAuth.js │ ├── login.js │ ├── index.js │ ├── loadInfo.js │ └── logout.js ├── utils │ └── url.js ├── __tests__ │ └── api-test.js └── api.js ├── static ├── logo.jpg ├── favicon.ico └── favicon.png ├── tests.webpack.js ├── .gitignore ├── src ├── components │ ├── InfoBar │ │ ├── InfoBar.scss │ │ └── InfoBar.js │ ├── SurveyForm │ │ ├── surveyValidation.js │ │ ├── SurveyForm.scss │ │ └── SurveyForm.js │ ├── WidgetForm │ │ ├── widgetValidation.js │ │ └── WidgetForm.js │ ├── index.js │ ├── MiniInfoBar │ │ └── MiniInfoBar.js │ ├── CounterButton │ │ └── CounterButton.js │ ├── GithubButton │ │ └── GithubButton.js │ └── __tests__ │ │ └── InfoBar-test.js ├── containers │ ├── Home │ │ ├── logo.png │ │ ├── Home.scss │ │ └── Home.js │ ├── About │ │ ├── kitten.jpg │ │ └── About.js │ ├── Chat │ │ ├── Chat.scss │ │ └── Chat.js │ ├── Login │ │ ├── Login.scss │ │ └── Login.js │ ├── NotFound │ │ └── NotFound.js │ ├── index.js │ ├── DevTools │ │ └── DevTools.js │ ├── App │ │ ├── App.scss │ │ └── App.js │ ├── Widgets │ │ ├── Widgets.scss │ │ └── Widgets.js │ ├── LoginSuccess │ │ └── LoginSuccess.js │ └── Survey │ │ └── Survey.js ├── theme │ ├── font-awesome.config.less │ ├── bootstrap.config.prod.js │ ├── font-awesome.config.prod.js │ ├── font-awesome.config.js │ ├── bootstrap.overrides.scss │ ├── variables.scss │ └── bootstrap.config.js ├── helpers │ ├── getStatusFromRoutes.js │ ├── getDataDependencies.js │ ├── connectData.js │ ├── __tests__ │ │ ├── connectData-test.js │ │ ├── getStatusFromRoutes-test.js │ │ ├── getDataDependencies-test.js │ │ └── makeRouteHooksSafe-test.js │ ├── makeRouteHooksSafe.js │ ├── ApiClient.js │ └── Html.js ├── redux │ ├── modules │ │ ├── counter.js │ │ ├── reducer.js │ │ ├── info.js │ │ ├── auth.js │ │ └── widgets.js │ ├── middleware │ │ ├── clientMiddleware.js │ │ └── transitionMiddleware.js │ └── create.js ├── config.js ├── routes.js ├── utils │ └── validation.js ├── client.js └── server.js ├── docs ├── Ducks.md └── InlineStyles.md ├── .editorconfig ├── .travis.yml ├── bin ├── api.js └── server.js ├── server.babel.js ├── .babelrc ├── app.json ├── webpack ├── webpack-dev-server.js ├── prod.config.js ├── webpack-isomorphic-tools.js └── dev.config.js ├── LICENSE ├── .eslintrc ├── karma.conf.js ├── CONTRIBUTING.md ├── package.json └── README.md /.env: -------------------------------------------------------------------------------- 1 | NODE_PATH=./src 2 | NODE_ENV=production 3 | PORT=80 4 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | webpack/* 2 | karma.conf.js 3 | tests.webpack.js 4 | -------------------------------------------------------------------------------- /api/actions/widget/index.js: -------------------------------------------------------------------------------- 1 | export update from './update'; 2 | export load from './load'; 3 | -------------------------------------------------------------------------------- /static/logo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mrshll/react-redux-universal-hot-example/master/static/logo.jpg -------------------------------------------------------------------------------- /static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mrshll/react-redux-universal-hot-example/master/static/favicon.ico -------------------------------------------------------------------------------- /static/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mrshll/react-redux-universal-hot-example/master/static/favicon.png -------------------------------------------------------------------------------- /tests.webpack.js: -------------------------------------------------------------------------------- 1 | var context = require.context('./src', true, /-test\.js$/); 2 | context.keys().forEach(context); 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | node_modules/ 3 | dist/ 4 | *.iml 5 | webpack-assets.json 6 | webpack-stats.json 7 | npm-debug.log 8 | -------------------------------------------------------------------------------- /api/actions/loadAuth.js: -------------------------------------------------------------------------------- 1 | export default function loadAuth(req) { 2 | return Promise.resolve(req.session.user || null); 3 | } 4 | -------------------------------------------------------------------------------- /src/components/InfoBar/InfoBar.scss: -------------------------------------------------------------------------------- 1 | .infoBar { 2 | font-variant: italics; 3 | } 4 | 5 | .time { 6 | margin: 0 30px; 7 | } 8 | -------------------------------------------------------------------------------- /src/containers/Home/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mrshll/react-redux-universal-hot-example/master/src/containers/Home/logo.png -------------------------------------------------------------------------------- /docs/Ducks.md: -------------------------------------------------------------------------------- 1 | This document has found [another, hopefully permanent, home](https://github.com/erikras/ducks-modular-redux). 2 | 3 | Quack. 4 | -------------------------------------------------------------------------------- /src/containers/About/kitten.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mrshll/react-redux-universal-hot-example/master/src/containers/About/kitten.jpg -------------------------------------------------------------------------------- /src/theme/font-awesome.config.less: -------------------------------------------------------------------------------- 1 | /** 2 | * Configuration file for font-awesome-webpack 3 | * 4 | */ 5 | 6 | // Example: 7 | // @fa-border-color: #ddd; 8 | -------------------------------------------------------------------------------- /api/actions/login.js: -------------------------------------------------------------------------------- 1 | export default function login(req) { 2 | const user = { 3 | name: req.body.name 4 | }; 5 | req.session.user = user; 6 | return Promise.resolve(user); 7 | } 8 | -------------------------------------------------------------------------------- /api/actions/index.js: -------------------------------------------------------------------------------- 1 | export loadInfo from './loadInfo'; 2 | export loadAuth from './loadAuth'; 3 | export login from './login'; 4 | export logout from './logout'; 5 | export * as widget from './widget/index'; 6 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | indent_style = space 3 | end_of_line = lf 4 | indent_size = 2 5 | charset = utf-8 6 | trim_trailing_whitespace = true 7 | 8 | [*.md] 9 | max_line_length = 0 10 | trim_trailing_whitespace = false 11 | -------------------------------------------------------------------------------- /api/actions/loadInfo.js: -------------------------------------------------------------------------------- 1 | export default function loadInfo() { 2 | return new Promise((resolve) => { 3 | resolve({ 4 | message: 'This came from the api server', 5 | time: Date.now() 6 | }); 7 | }); 8 | } 9 | -------------------------------------------------------------------------------- /api/actions/logout.js: -------------------------------------------------------------------------------- 1 | export default function logout(req) { 2 | return new Promise((resolve) => { 3 | req.session.destroy(() => { 4 | req.session = null; 5 | return resolve(null); 6 | }); 7 | }); 8 | } 9 | -------------------------------------------------------------------------------- /src/containers/Chat/Chat.scss: -------------------------------------------------------------------------------- 1 | .chat { 2 | input { 3 | padding: 5px 10px; 4 | border-radius: 5px; 5 | border: 1px solid #ccc; 6 | } 7 | form { 8 | margin: 30px 0; 9 | :global(.btn) { 10 | margin-left: 10px; 11 | } 12 | } 13 | } -------------------------------------------------------------------------------- /src/containers/Login/Login.scss: -------------------------------------------------------------------------------- 1 | .loginPage { 2 | input { 3 | padding: 5px 10px; 4 | border-radius: 5px; 5 | border: 1px solid #ccc; 6 | } 7 | form { 8 | margin: 30px 0; 9 | :global(.btn) { 10 | margin-left: 10px; 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | node_js: 4 | - "0.12" 5 | - "4.0" 6 | - "4" 7 | 8 | sudo: false 9 | 10 | before_script: 11 | - export DISPLAY=:99.0 12 | - sh -e /etc/init.d/xvfb start 13 | 14 | script: 15 | - npm run lint 16 | - npm test 17 | - npm run test-node 18 | -------------------------------------------------------------------------------- /src/theme/bootstrap.config.prod.js: -------------------------------------------------------------------------------- 1 | const bootstrapConfig = require('./bootstrap.config.js'); 2 | const ExtractTextPlugin = require('extract-text-webpack-plugin'); 3 | bootstrapConfig.styleLoader = ExtractTextPlugin.extract('style-loader', 'css-loader!sass-loader'); 4 | module.exports = bootstrapConfig; 5 | 6 | -------------------------------------------------------------------------------- /src/theme/font-awesome.config.prod.js: -------------------------------------------------------------------------------- 1 | const fontAwesomeConfig = require('./font-awesome.config.js'); 2 | const ExtractTextPlugin = require('extract-text-webpack-plugin'); 3 | fontAwesomeConfig.styleLoader = ExtractTextPlugin.extract('style-loader', 'css-loader!less-loader'); 4 | module.exports = fontAwesomeConfig; 5 | 6 | -------------------------------------------------------------------------------- /src/helpers/getStatusFromRoutes.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Return the status code from the last matched route with a status property. 3 | * 4 | * @param matchedRoutes 5 | * @returns {Number|null} 6 | */ 7 | export default (matchedRoutes) => { 8 | return matchedRoutes.reduce((prev, cur) => cur.status || prev, null); 9 | }; 10 | -------------------------------------------------------------------------------- /bin/api.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | if (process.env.NODE_ENV !== 'production') { 3 | if (!require('piping')({ 4 | hook: true, 5 | ignore: /(\/\.|~$|\.json$)/i 6 | })) { 7 | return; 8 | } 9 | } 10 | require('../server.babel'); // babel registration (runtime transpilation for node) 11 | require('../api/api'); 12 | -------------------------------------------------------------------------------- /src/containers/NotFound/NotFound.js: -------------------------------------------------------------------------------- 1 | import React, {Component} from 'react'; 2 | 3 | export default class NotFound extends Component { 4 | render() { 5 | return ( 6 |
7 |

Doh! 404!

8 |

These are not the droids you are looking for!

9 |
10 | ); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/theme/font-awesome.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Configuration file for font-awesome-webpack 3 | * 4 | * In order to keep the bundle size low in production, 5 | * disable components you don't use. 6 | * 7 | */ 8 | 9 | module.exports = { 10 | styles: { 11 | mixins: true, 12 | core: true, 13 | icons: true, 14 | larger: true, 15 | path: true, 16 | } 17 | }; 18 | -------------------------------------------------------------------------------- /server.babel.js: -------------------------------------------------------------------------------- 1 | // enable runtime transpilation to use ES6/7 in node 2 | 3 | var fs = require('fs'); 4 | 5 | var babelrc = fs.readFileSync('./.babelrc'); 6 | var config; 7 | 8 | try { 9 | config = JSON.parse(babelrc); 10 | } catch (err) { 11 | console.error('==> ERROR: Error parsing your .babelrc.'); 12 | console.error(err); 13 | } 14 | 15 | require('babel-core/register')(config); 16 | -------------------------------------------------------------------------------- /src/containers/index.js: -------------------------------------------------------------------------------- 1 | export App from './App/App'; 2 | export Chat from './Chat/Chat'; 3 | export Home from './Home/Home'; 4 | export Widgets from './Widgets/Widgets'; 5 | export About from './About/About'; 6 | export Login from './Login/Login'; 7 | export LoginSuccess from './LoginSuccess/LoginSuccess'; 8 | export Survey from './Survey/Survey'; 9 | export NotFound from './NotFound/NotFound'; 10 | -------------------------------------------------------------------------------- /src/containers/DevTools/DevTools.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { createDevTools } from 'redux-devtools'; 3 | import LogMonitor from 'redux-devtools-log-monitor'; 4 | import DockMonitor from 'redux-devtools-dock-monitor'; 5 | 6 | export default createDevTools( 7 | 9 | 10 | 11 | ); 12 | -------------------------------------------------------------------------------- /src/components/SurveyForm/surveyValidation.js: -------------------------------------------------------------------------------- 1 | import memoize from 'lru-memoize'; 2 | import {createValidator, required, maxLength, email} from 'utils/validation'; 3 | 4 | const surveyValidation = createValidator({ 5 | name: [required, maxLength(10)], 6 | email: [required, email], 7 | occupation: maxLength(20) // single rules don't have to be in an array 8 | }); 9 | export default memoize(10)(surveyValidation); 10 | -------------------------------------------------------------------------------- /src/components/WidgetForm/widgetValidation.js: -------------------------------------------------------------------------------- 1 | import {createValidator, required, maxLength, integer, oneOf} from 'utils/validation'; 2 | 3 | export const colors = ['Blue', 'Fuchsia', 'Green', 'Orange', 'Red', 'Taupe']; 4 | 5 | const widgetValidation = createValidator({ 6 | color: [required, oneOf(colors)], 7 | sprocketCount: [required, integer], 8 | owner: [required, maxLength(30)] 9 | }); 10 | export default widgetValidation; 11 | -------------------------------------------------------------------------------- /src/theme/bootstrap.overrides.scss: -------------------------------------------------------------------------------- 1 | /** 2 | * Override Bootstrap styles that you can't modify via variables here. 3 | * 4 | */ 5 | 6 | .navbar-brand { 7 | position: relative; 8 | padding-left: 50px; 9 | } 10 | 11 | .navbar-default .navbar-nav > .active > a, 12 | .navbar-default .navbar-nav > .active > a:hover, 13 | .navbar-default .navbar-nav > .active > a:focus { 14 | color: #33e0ff; 15 | background-color: transparent; 16 | } 17 | -------------------------------------------------------------------------------- /src/components/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Point of contact for component modules 3 | * 4 | * ie: import { CounterButton, InfoBar } from 'components'; 5 | * 6 | */ 7 | 8 | export CounterButton from './CounterButton/CounterButton'; 9 | export GithubButton from './GithubButton/GithubButton'; 10 | export InfoBar from './InfoBar/InfoBar'; 11 | export MiniInfoBar from './MiniInfoBar/MiniInfoBar'; 12 | export SurveyForm from './SurveyForm/SurveyForm'; 13 | export WidgetForm from './WidgetForm/WidgetForm'; 14 | -------------------------------------------------------------------------------- /src/redux/modules/counter.js: -------------------------------------------------------------------------------- 1 | const INCREMENT = 'redux-example/counter/INCREMENT'; 2 | 3 | const initialState = { 4 | count: 0 5 | }; 6 | 7 | export default function reducer(state = initialState, action = {}) { 8 | switch (action.type) { 9 | case INCREMENT: 10 | const {count} = state; 11 | return { 12 | count: count + 1 13 | }; 14 | default: 15 | return state; 16 | } 17 | } 18 | 19 | export function increment() { 20 | return { 21 | type: INCREMENT 22 | }; 23 | } 24 | -------------------------------------------------------------------------------- /src/containers/App/App.scss: -------------------------------------------------------------------------------- 1 | .app { 2 | .brand { 3 | position: absolute; 4 | $size: 40px; 5 | top: 5px; 6 | left: 5px; 7 | display: inline-block; 8 | background: #2d2d2d url('../Home/logo.png') no-repeat center center; 9 | width: $size; 10 | height: $size; 11 | background-size: 80%; 12 | margin: 0 10px 0 0; 13 | border-radius: $size / 2; 14 | } 15 | nav :global(.fa) { 16 | font-size: 2em; 17 | line-height: 20px; 18 | } 19 | } 20 | .appContent { 21 | margin: 50px 0; // for fixed navbar 22 | } 23 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "stage": 0, 3 | "optional": "runtime", 4 | "loose": "all", 5 | "plugins": [ 6 | "typecheck" 7 | ], 8 | "env": { 9 | "development": { 10 | "plugins": [ 11 | "react-transform" 12 | ], 13 | "extra": { 14 | "react-transform": { 15 | "transforms": [{ 16 | "transform": "react-transform-catch-errors", 17 | "imports": [ 18 | "react", 19 | "redbox-react" 20 | ] 21 | }] 22 | } 23 | } 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-redux-universal-hot-example", 3 | "description": "Example of an isomorphic (universal) webapp using react redux and hot reloading", 4 | "repository": "https://github.com/erikras/react-redux-universal-hot-example", 5 | "logo": "http://node-js-sample.herokuapp.com/node.svg", 6 | "keywords": [ 7 | "react", 8 | "isomorphic", 9 | "universal", 10 | "webpack", 11 | "express", 12 | "hot reloading", 13 | "react-hot-reloader", 14 | "redux", 15 | "starter", 16 | "boilerplate", 17 | "babel" 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /src/theme/variables.scss: -------------------------------------------------------------------------------- 1 | /** 2 | * Define scss variables here. 3 | * 4 | * Available options for Bootstrap: 5 | * http://getbootstrap.com/customize/ 6 | * 7 | */ 8 | 9 | // Custom Colors 10 | $cyan: #33e0ff; 11 | $humility: #777; 12 | 13 | // Bootstrap Variables 14 | $brand-primary: darken(#428bca, 6.5%); 15 | $brand-secondary: #e25139; 16 | $brand-success: #5cb85c; 17 | $brand-warning: #f0ad4e; 18 | $brand-danger: #d9534f; 19 | $brand-info: #5bc0de; 20 | 21 | $text-color: #333; 22 | 23 | $font-size-base: 14px; 24 | $font-family-sans-serif: "Helvetica Neue", Helvetica, sans-serif; 25 | -------------------------------------------------------------------------------- /src/components/MiniInfoBar/MiniInfoBar.js: -------------------------------------------------------------------------------- 1 | import React, {Component, PropTypes} from 'react'; 2 | import {connect} from 'react-redux'; 3 | 4 | @connect(state => ({ time: state.info.data.time })) 5 | export default class MiniInfoBar extends Component { 6 | static propTypes = { 7 | time: PropTypes.number 8 | } 9 | 10 | render() { 11 | const {time} = this.props; 12 | return ( 13 |
14 | The info bar was last loaded at 15 | {' '} 16 | {time && new Date(time).toString()} 17 |
18 | ); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/redux/modules/reducer.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux'; 2 | import multireducer from 'multireducer'; 3 | import { routerStateReducer } from 'redux-router'; 4 | 5 | import auth from './auth'; 6 | import counter from './counter'; 7 | import {reducer as form} from 'redux-form'; 8 | import info from './info'; 9 | import widgets from './widgets'; 10 | 11 | export default combineReducers({ 12 | router: routerStateReducer, 13 | auth, 14 | form, 15 | multireducer: multireducer({ 16 | counter1: counter, 17 | counter2: counter, 18 | counter3: counter 19 | }), 20 | info, 21 | widgets 22 | }); 23 | -------------------------------------------------------------------------------- /src/containers/Widgets/Widgets.scss: -------------------------------------------------------------------------------- 1 | .widgets { 2 | .refreshBtn { 3 | margin-left: 20px; 4 | } 5 | .idCol { 6 | width: 5%; 7 | } 8 | .colorCol { 9 | width: 20%; 10 | } 11 | .sprocketsCol { 12 | width: 20%; 13 | text-align: right; 14 | input { 15 | text-align: right; 16 | } 17 | } 18 | .ownerCol { 19 | width: 30%; 20 | } 21 | .buttonCol { 22 | width: 25%; 23 | :global(.btn) { 24 | margin: 0 5px; 25 | } 26 | } 27 | tr.saving { 28 | opacity: 0.8; 29 | :global(.btn) { 30 | &[disabled] { 31 | opacity: 1; 32 | } 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/helpers/getDataDependencies.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 1. Skip holes in route component chain and 3 | * only consider components that implement 4 | * fetchData or fetchDataDeferred 5 | * 6 | * 2. Pull out fetch data methods 7 | * 8 | * 3. Call fetch data methods and gather promises 9 | */ 10 | export default (components, getState, dispatch, location, params, deferred) => { 11 | const methodName = deferred ? 'fetchDataDeferred' : 'fetchData'; 12 | 13 | return components 14 | .filter((component) => component && component[methodName]) // 1 15 | .map((component) => component[methodName]) // 2 16 | .map(fetchData => 17 | fetchData(getState, dispatch, location, params)); // 3 18 | }; 19 | -------------------------------------------------------------------------------- /src/helpers/connectData.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import hoistStatics from 'hoist-non-react-statics'; 3 | 4 | /* 5 | Note: 6 | When this decorator is used, it MUST be the first (outermost) decorator. 7 | Otherwise, we cannot find and call the fetchData and fetchDataDeffered methods. 8 | */ 9 | 10 | export default function connectData(fetchData, fetchDataDeferred) { 11 | 12 | return function wrapWithFetchData(WrappedComponent) { 13 | class ConnectData extends Component { 14 | render() { 15 | return ; 16 | } 17 | } 18 | 19 | ConnectData.fetchData = fetchData; 20 | ConnectData.fetchDataDeferred = fetchDataDeferred; 21 | 22 | return hoistStatics(ConnectData, WrappedComponent); 23 | }; 24 | } 25 | -------------------------------------------------------------------------------- /src/redux/middleware/clientMiddleware.js: -------------------------------------------------------------------------------- 1 | export default function clientMiddleware(client) { 2 | return ({dispatch, getState}) => { 3 | return next => action => { 4 | if (typeof action === 'function') { 5 | return action(dispatch, getState); 6 | } 7 | 8 | const { promise, types, ...rest } = action; 9 | if (!promise) { 10 | return next(action); 11 | } 12 | 13 | const [REQUEST, SUCCESS, FAILURE] = types; 14 | next({...rest, type: REQUEST}); 15 | return promise(client).then( 16 | (result) => next({...rest, result, type: SUCCESS}), 17 | (error) => next({...rest, error, type: FAILURE}) 18 | ).catch((error)=> { 19 | console.error('MIDDLEWARE ERROR:', error); 20 | next({...rest, error, type: FAILURE}); 21 | }); 22 | }; 23 | }; 24 | } 25 | -------------------------------------------------------------------------------- /api/actions/widget/update.js: -------------------------------------------------------------------------------- 1 | import load from './load'; 2 | 3 | export default function update(req) { 4 | return new Promise((resolve, reject) => { 5 | // write to database 6 | setTimeout(() => { 7 | if (Math.random() < 0.2) { 8 | reject('Oh no! Widget save fails 20% of the time. Try again.'); 9 | } else { 10 | const widgets = load(req); 11 | const widget = req.body; 12 | if (widget.color === 'Green') { 13 | reject({ 14 | color: 'We do not accept green widgets' // example server-side validation error 15 | }); 16 | } 17 | if (widget.id) { 18 | widgets[widget.id - 1] = widget; // id is 1-based. please don't code like this in production! :-) 19 | } 20 | resolve(widget); 21 | } 22 | }, 2000); // simulate async db write 23 | }); 24 | } 25 | -------------------------------------------------------------------------------- /src/components/SurveyForm/SurveyForm.scss: -------------------------------------------------------------------------------- 1 | .inputGroup { 2 | position: relative; 3 | } 4 | 5 | .flags { 6 | position: absolute; 7 | right: 20px; 8 | top: 7px; 9 | & > * { 10 | margin: 0 2px; 11 | width: 20px; 12 | height: 20px; 13 | border-radius: 20px; 14 | box-shadow: 1px 1px 3px rgba(0, 0, 0, 0.4); 15 | color: white; 16 | float: right; 17 | text-align: center; 18 | } 19 | .active { 20 | background: linear-gradient(#cc0, #aa0); 21 | color: black; 22 | } 23 | .dirty { 24 | background: linear-gradient(#090, #060); 25 | } 26 | .visited { 27 | background: linear-gradient(#009, #006); 28 | } 29 | .touched { 30 | background: linear-gradient(#099, #066); 31 | } 32 | } 33 | 34 | .radioLabel { 35 | margin: 0 25px 0 5px; 36 | } 37 | .cog { 38 | position: absolute; 39 | left: 0; 40 | top: 10px; 41 | } 42 | -------------------------------------------------------------------------------- /api/actions/widget/load.js: -------------------------------------------------------------------------------- 1 | const initialWidgets = [ 2 | {id: 1, color: 'Red', sprocketCount: 7, owner: 'John'}, 3 | {id: 2, color: 'Taupe', sprocketCount: 1, owner: 'George'}, 4 | {id: 3, color: 'Green', sprocketCount: 8, owner: 'Ringo'}, 5 | {id: 4, color: 'Blue', sprocketCount: 2, owner: 'Paul'} 6 | ]; 7 | 8 | export function getWidgets(req) { 9 | let widgets = req.session.widgets; 10 | if (!widgets) { 11 | widgets = initialWidgets; 12 | req.session.widgets = widgets; 13 | } 14 | return widgets; 15 | } 16 | 17 | export default function load(req) { 18 | return new Promise((resolve, reject) => { 19 | // make async call to database 20 | setTimeout(() => { 21 | if (Math.random() < 0.33) { 22 | reject('Widget load fails 33% of the time. You were unlucky.'); 23 | } else { 24 | resolve(getWidgets(req)); 25 | } 26 | }, 1000); // simulate async load 27 | }); 28 | } 29 | -------------------------------------------------------------------------------- /api/utils/url.js: -------------------------------------------------------------------------------- 1 | export function mapUrl(availableActions = {}, url = []) { 2 | 3 | const notFound = {action: null, params: []}; 4 | 5 | // test for empty input 6 | if (url.length === 0 || Object.keys(availableActions).length === 0) { 7 | return notFound; 8 | } 9 | /*eslint-disable */ 10 | const reducer = (next, current) => { 11 | if (next.action && next.action[current]) { 12 | return {action: next.action[current], params: []}; // go deeper 13 | } else { 14 | if (typeof next.action === 'function') { 15 | return {action: next.action, params: next.params.concat(current)}; // params are found 16 | } else { 17 | return notFound; 18 | } 19 | } 20 | }; 21 | /*eslint-enable */ 22 | 23 | const actionAndParams = url.reduce(reducer, {action: availableActions, params: []}); 24 | 25 | return (typeof actionAndParams.action === 'function') ? actionAndParams : notFound; 26 | } 27 | -------------------------------------------------------------------------------- /src/components/CounterButton/CounterButton.js: -------------------------------------------------------------------------------- 1 | import React, {Component, PropTypes} from 'react'; 2 | import {connectMultireducer} from 'multireducer'; 3 | import {increment} from 'redux/modules/counter'; 4 | 5 | @connectMultireducer( 6 | state => ({count: state.count}), 7 | {increment}) 8 | export default class CounterButton extends Component { 9 | static propTypes = { 10 | count: PropTypes.number, 11 | increment: PropTypes.func.isRequired, 12 | className: PropTypes.string 13 | } 14 | 15 | props = { 16 | className: '' 17 | } 18 | 19 | render() { 20 | const {count, increment} = this.props; // eslint-disable-line no-shadow 21 | let {className} = this.props; 22 | className += ' btn btn-default'; 23 | return ( 24 | 27 | ); 28 | } 29 | } 30 | 31 | -------------------------------------------------------------------------------- /src/helpers/__tests__/connectData-test.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import React from 'react'; 3 | import { div } from 'react-dom'; 4 | import connectData from '../connectData'; 5 | 6 | describe('connectData', () => { 7 | let fetchData; 8 | let fetchDataDeferred; 9 | let WrappedComponent; 10 | let DataComponent; 11 | 12 | beforeEach(() => { 13 | fetchData = 'fetchDataFunction'; 14 | fetchDataDeferred = 'fetchDataDeferredFunction'; 15 | 16 | WrappedComponent = () => 17 |
; 18 | 19 | DataComponent = connectData(fetchData, fetchDataDeferred)(WrappedComponent); 20 | }); 21 | 22 | it('should set fetchData as a static property of the final component', () => { 23 | expect(DataComponent.fetchData).to.equal(fetchData); 24 | }); 25 | 26 | it('should set fetchDataDeferred as a static property of the final component', () => { 27 | expect(DataComponent.fetchDataDeferred).to.equal(fetchDataDeferred); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /bin/server.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | require('../server.babel'); // babel registration (runtime transpilation for node) 3 | var path = require('path'); 4 | var rootDir = path.resolve(__dirname, '..'); 5 | /** 6 | * Define isomorphic constants. 7 | */ 8 | global.__CLIENT__ = false; 9 | global.__SERVER__ = true; 10 | global.__DISABLE_SSR__ = false; // <----- DISABLES SERVER SIDE RENDERING FOR ERROR DEBUGGING 11 | global.__DEVELOPMENT__ = process.env.NODE_ENV !== 'production'; 12 | 13 | if (__DEVELOPMENT__) { 14 | if (!require('piping')({ 15 | hook: true, 16 | ignore: /(\/\.|~$|\.json|\.scss$)/i 17 | })) { 18 | return; 19 | } 20 | } 21 | 22 | // https://github.com/halt-hammerzeit/webpack-isomorphic-tools 23 | var WebpackIsomorphicTools = require('webpack-isomorphic-tools'); 24 | global.webpackIsomorphicTools = new WebpackIsomorphicTools(require('../webpack/webpack-isomorphic-tools')) 25 | .development(__DEVELOPMENT__) 26 | .server(rootDir, function() { 27 | require('../src/server'); 28 | }); 29 | -------------------------------------------------------------------------------- /webpack/webpack-dev-server.js: -------------------------------------------------------------------------------- 1 | var Express = require('express'); 2 | var webpack = require('webpack'); 3 | 4 | var config = require('../src/config'); 5 | var webpackConfig = require('./dev.config'); 6 | var compiler = webpack(webpackConfig); 7 | 8 | var host = process.env.HOST || 'localhost'; 9 | var port = parseInt(config.port, 10) + 1 || 3001; 10 | var serverOptions = { 11 | contentBase: 'http://' + host + ':' + port, 12 | quiet: true, 13 | noInfo: true, 14 | hot: true, 15 | inline: true, 16 | lazy: false, 17 | publicPath: webpackConfig.output.publicPath, 18 | headers: {'Access-Control-Allow-Origin': '*'}, 19 | stats: {colors: true} 20 | }; 21 | 22 | var app = new Express(); 23 | 24 | app.use(require('webpack-dev-middleware')(compiler, serverOptions)); 25 | app.use(require('webpack-hot-middleware')(compiler)); 26 | 27 | app.listen(port, function onAppListening(err) { 28 | if (err) { 29 | console.error(err); 30 | } else { 31 | console.info('==> 🚧 Webpack development server listening on port %s', port); 32 | } 33 | }); 34 | -------------------------------------------------------------------------------- /src/redux/modules/info.js: -------------------------------------------------------------------------------- 1 | const LOAD = 'redux-example/LOAD'; 2 | const LOAD_SUCCESS = 'redux-example/LOAD_SUCCESS'; 3 | const LOAD_FAIL = 'redux-example/LOAD_FAIL'; 4 | 5 | const initialState = { 6 | loaded: false 7 | }; 8 | 9 | export default function info(state = initialState, action = {}) { 10 | switch (action.type) { 11 | case LOAD: 12 | return { 13 | ...state, 14 | loading: true 15 | }; 16 | case LOAD_SUCCESS: 17 | return { 18 | ...state, 19 | loading: false, 20 | loaded: true, 21 | data: action.result 22 | }; 23 | case LOAD_FAIL: 24 | return { 25 | ...state, 26 | loading: false, 27 | loaded: false, 28 | error: action.error 29 | }; 30 | default: 31 | return state; 32 | } 33 | } 34 | 35 | export function isLoaded(globalState) { 36 | return globalState.info && globalState.info.loaded; 37 | } 38 | 39 | export function load() { 40 | return { 41 | types: [LOAD, LOAD_SUCCESS, LOAD_FAIL], 42 | promise: (client) => client.get('/loadInfo') 43 | }; 44 | } 45 | -------------------------------------------------------------------------------- /src/components/InfoBar/InfoBar.js: -------------------------------------------------------------------------------- 1 | import React, {Component, PropTypes} from 'react'; 2 | import {bindActionCreators} from 'redux'; 3 | import {connect} from 'react-redux'; 4 | import {load} from 'redux/modules/info'; 5 | 6 | @connect( 7 | state => ({info: state.info.data}), 8 | dispatch => bindActionCreators({load}, dispatch)) 9 | export default class InfoBar extends Component { 10 | static propTypes = { 11 | info: PropTypes.object, 12 | load: PropTypes.func.isRequired 13 | } 14 | 15 | render() { 16 | const {info, load} = this.props; // eslint-disable-line no-shadow 17 | const styles = require('./InfoBar.scss'); 18 | return ( 19 |
20 |
21 | This is an info bar 22 | {' '} 23 | {info ? info.message : 'no info!'} 24 | {info && new Date(info.time).toString()} 25 | 26 |
27 |
28 | ); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/containers/Home/Home.scss: -------------------------------------------------------------------------------- 1 | @import "../../theme/variables.scss"; 2 | 3 | .home { 4 | dd { 5 | margin-bottom: 15px; 6 | } 7 | } 8 | .masthead { 9 | background: #2d2d2d; 10 | padding: 40px 20px; 11 | color: white; 12 | text-align: center; 13 | .logo { 14 | $size: 200px; 15 | margin: auto; 16 | height: $size; 17 | width: $size; 18 | border-radius: $size / 2; 19 | border: 1px solid $cyan; 20 | box-shadow: inset 0 0 10px $cyan; 21 | vertical-align: middle; 22 | p { 23 | line-height: $size; 24 | margin: 0px; 25 | } 26 | img { 27 | width: 75%; 28 | margin: auto; 29 | } 30 | } 31 | h1 { 32 | color: $cyan; 33 | font-size: 4em; 34 | } 35 | h2 { 36 | color: #ddd; 37 | font-size: 2em; 38 | margin: 20px; 39 | } 40 | a { 41 | color: #ddd; 42 | } 43 | p { 44 | margin: 10px; 45 | } 46 | .humility { 47 | color: $humility; 48 | a { 49 | color: $humility; 50 | } 51 | } 52 | .github { 53 | font-size: 1.5em; 54 | } 55 | } 56 | 57 | .counterContainer { 58 | text-align: center; 59 | margin: 20px; 60 | } 61 | -------------------------------------------------------------------------------- /src/components/GithubButton/GithubButton.js: -------------------------------------------------------------------------------- 1 | import React, {Component, PropTypes} from 'react'; 2 | 3 | export default class GithubButton extends Component { 4 | static propTypes = { 5 | user: PropTypes.string.isRequired, 6 | repo: PropTypes.string.isRequired, 7 | type: PropTypes.oneOf(['star', 'watch', 'fork', 'follow']).isRequired, 8 | width: PropTypes.number.isRequired, 9 | height: PropTypes.number.isRequired, 10 | count: PropTypes.bool, 11 | large: PropTypes.bool 12 | } 13 | 14 | render() { 15 | const {user, repo, type, width, height, count, large} = this.props; 16 | let src = `https://ghbtns.com/github-btn.html?user=${user}&repo=${repo}&type=${type}`; 17 | if (count) { 18 | src += '&count=true'; 19 | } 20 | if (large) { 21 | src += '&size=large'; 22 | } 23 | return ( 24 | 31 | ); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Erik Rasmussen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/helpers/makeRouteHooksSafe.js: -------------------------------------------------------------------------------- 1 | import { createRoutes } from 'react-router/lib/RouteUtils'; 2 | 3 | // Wrap the hooks so they don't fire if they're called before 4 | // the store is initialised. This only happens when doing the first 5 | // client render of a route that has an onEnter hook 6 | function makeHooksSafe(routes, store) { 7 | if (Array.isArray(routes)) { 8 | return routes.map((route) => makeHooksSafe(route, store)); 9 | } 10 | 11 | const onEnter = routes.onEnter; 12 | 13 | if (onEnter) { 14 | routes.onEnter = function safeOnEnter(...args) { 15 | try { 16 | store.getState(); 17 | } catch (err) { 18 | if (onEnter.length === 3) { 19 | args[2](); 20 | } 21 | 22 | // There's no store yet so ignore the hook 23 | return; 24 | } 25 | 26 | onEnter.apply(null, args); 27 | }; 28 | } 29 | 30 | if (routes.childRoutes) { 31 | makeHooksSafe(routes.childRoutes, store); 32 | } 33 | 34 | if (routes.indexRoute) { 35 | makeHooksSafe(routes.indexRoute, store); 36 | } 37 | 38 | return routes; 39 | } 40 | 41 | export default function makeRouteHooksSafe(_getRoutes) { 42 | return (store) => makeHooksSafe(createRoutes(_getRoutes(store)), store); 43 | } 44 | -------------------------------------------------------------------------------- /src/containers/LoginSuccess/LoginSuccess.js: -------------------------------------------------------------------------------- 1 | import React, {Component, PropTypes} from 'react'; 2 | import {connect} from 'react-redux'; 3 | import * as authActions from 'redux/modules/auth'; 4 | 5 | @connect( 6 | state => ({user: state.auth.user}), 7 | authActions) 8 | export default 9 | class LoginSuccess extends Component { 10 | static propTypes = { 11 | user: PropTypes.object, 12 | logout: PropTypes.func 13 | } 14 | 15 | render() { 16 | const {user, logout} = this.props; 17 | return (user && 18 |
19 |

Login Success

20 | 21 |
22 |

Hi, {user.name}. You have just successfully logged in, and were forwarded here 23 | by componentWillReceiveProps() in App.js, which is listening to 24 | the auth reducer via redux @connect. How exciting! 25 |

26 | 27 |

28 | The same function will forward you to / should you chose to log out. The choice is yours... 29 |

30 | 31 |
32 | 33 |
34 |
35 |
36 | ); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/redux/create.js: -------------------------------------------------------------------------------- 1 | import { createStore as _createStore, applyMiddleware, compose } from 'redux'; 2 | import createMiddleware from './middleware/clientMiddleware'; 3 | import transitionMiddleware from './middleware/transitionMiddleware'; 4 | 5 | export default function createStore(reduxReactRouter, getRoutes, createHistory, client, data) { 6 | const middleware = [createMiddleware(client), transitionMiddleware]; 7 | 8 | let finalCreateStore; 9 | if (__DEVELOPMENT__ && __CLIENT__ && __DEVTOOLS__) { 10 | const { persistState } = require('redux-devtools'); 11 | const DevTools = require('../containers/DevTools/DevTools'); 12 | finalCreateStore = compose( 13 | applyMiddleware(...middleware), 14 | DevTools.instrument(), 15 | persistState(window.location.href.match(/[?&]debug_session=([^&]+)\b/)) 16 | )(_createStore); 17 | } else { 18 | finalCreateStore = applyMiddleware(...middleware)(_createStore); 19 | } 20 | 21 | finalCreateStore = reduxReactRouter({ getRoutes, createHistory })(finalCreateStore); 22 | 23 | const reducer = require('./modules/reducer'); 24 | const store = finalCreateStore(reducer, data); 25 | 26 | if (__DEVELOPMENT__ && module.hot) { 27 | module.hot.accept('./modules/reducer', () => { 28 | store.replaceReducer(require('./modules/reducer')); 29 | }); 30 | } 31 | 32 | return store; 33 | } 34 | -------------------------------------------------------------------------------- /src/config.js: -------------------------------------------------------------------------------- 1 | require('babel/polyfill'); 2 | 3 | const environment = { 4 | development: { 5 | isProduction: false 6 | }, 7 | production: { 8 | isProduction: true 9 | } 10 | }[process.env.NODE_ENV || 'development']; 11 | 12 | module.exports = Object.assign({ 13 | host: process.env.HOST || 'localhost', 14 | port: process.env.PORT, 15 | apiHost: process.env.APIHOST || 'localhost', 16 | apiPort: process.env.APIPORT, 17 | app: { 18 | title: 'React Redux Example', 19 | description: 'All the modern best practices in one example.', 20 | meta: { 21 | charSet: 'utf-8', 22 | property: { 23 | 'og:site_name': 'React Redux Example', 24 | 'og:image': 'https://react-redux.herokuapp.com/logo.jpg', 25 | 'og:locale': 'en_US', 26 | 'og:title': 'React Redux Example', 27 | 'og:description': 'All the modern best practices in one example.', 28 | 'twitter:card': 'summary', 29 | 'twitter:site': '@erikras', 30 | 'twitter:creator': '@erikras', 31 | 'twitter:title': 'React Redux Example', 32 | 'twitter:description': 'All the modern best practices in one example.', 33 | 'twitter:image': 'https://react-redux.herokuapp.com/logo.jpg', 34 | 'twitter:image:width': '200', 35 | 'twitter:image:height': '200' 36 | } 37 | } 38 | } 39 | }, environment); 40 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { "extends": "eslint-config-airbnb", 2 | "env": { 3 | "browser": true, 4 | "node": true, 5 | "mocha": true 6 | }, 7 | "rules": { 8 | "react/jsx-uses-react": 2, 9 | "react/jsx-uses-vars": 2, 10 | "react/react-in-jsx-scope": 2, 11 | "react/jsx-quotes": 0, 12 | "react/no-multi-comp": 0, 13 | "import/default": 0, 14 | "import/no-duplicates": 0, 15 | "import/named": 0, 16 | "import/namespace": 0, 17 | "import/no-unresolved": 0, 18 | "import/no-named-as-default": 2, 19 | "jsx-quotes": 2, 20 | // Temporarirly disabled due to a possible bug in babel-eslint (todomvc example) 21 | "block-scoped-var": 0, 22 | // Temporarily disabled for test/* until babel/babel-eslint#33 is resolved 23 | "padded-blocks": 0, 24 | "comma-dangle": 0, // not sure why airbnb turned this on. gross! 25 | "indent": [2, 2, {"SwitchCase": 1}], 26 | "no-console": 0, 27 | "no-alert": 0 28 | }, 29 | "plugins": [ 30 | "react", "import" 31 | ], 32 | "settings": { 33 | "import/parser": "babel-eslint", 34 | "import/resolve": { 35 | moduleDirectory: ["node_modules", "src"] 36 | } 37 | }, 38 | "globals": { 39 | "__DEVELOPMENT__": true, 40 | "__CLIENT__": true, 41 | "__SERVER__": true, 42 | "__DISABLE_SSR__": true, 43 | "__DEVTOOLS__": true, 44 | "socket": true, 45 | "webpackIsomorphicTools": true 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/helpers/__tests__/getStatusFromRoutes-test.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import getStatusFromRoutes from '../getStatusFromRoutes'; 3 | 4 | describe('getStatusFromRoutes', () => { 5 | 6 | it('should return null when no routes have status code', () => { 7 | const status = getStatusFromRoutes([ 8 | {}, {} 9 | ]); 10 | 11 | expect(status).to.equal(null); 12 | }); 13 | 14 | it('should return the only status code', () => { 15 | const status = getStatusFromRoutes([ 16 | {status: 404} 17 | ]); 18 | 19 | expect(status).to.equal(404); 20 | }); 21 | 22 | it('should return the only status code when other routes have none', () => { 23 | const status = getStatusFromRoutes([ 24 | {status: 404}, {}, {} 25 | ]); 26 | 27 | expect(status).to.equal(404); 28 | }); 29 | 30 | it('should return the last status code when later routes have none', () => { 31 | const status = getStatusFromRoutes([ 32 | {status: 200}, {status: 404}, {} 33 | ]); 34 | 35 | expect(status).to.equal(404); 36 | }); 37 | 38 | it('should return the last status code when previous routes have one', () => { 39 | const status = getStatusFromRoutes([ 40 | {status: 200}, {}, {status: 404} 41 | ]); 42 | 43 | expect(status).to.equal(404); 44 | }); 45 | 46 | it('should return the last status code', () => { 47 | const status = getStatusFromRoutes([ 48 | {}, {}, {status: 404} 49 | ]); 50 | 51 | expect(status).to.equal(404); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /src/helpers/ApiClient.js: -------------------------------------------------------------------------------- 1 | import superagent from 'superagent'; 2 | import config from '../config'; 3 | 4 | const methods = ['get', 'post', 'put', 'patch', 'del']; 5 | 6 | function formatUrl(path) { 7 | const adjustedPath = path[0] !== '/' ? '/' + path : path; 8 | if (__SERVER__) { 9 | // Prepend host and port of the API server to the path. 10 | return 'http://' + config.apiHost + ':' + config.apiPort + adjustedPath; 11 | } 12 | // Prepend `/api` to relative URL, to proxy to API server. 13 | return '/api' + adjustedPath; 14 | } 15 | 16 | /* 17 | * This silly underscore is here to avoid a mysterious "ReferenceError: ApiClient is not defined" error. 18 | * See Issue #14. https://github.com/erikras/react-redux-universal-hot-example/issues/14 19 | * 20 | * Remove it at your own risk. 21 | */ 22 | class _ApiClient { 23 | constructor(req) { 24 | methods.forEach((method) => 25 | this[method] = (path, { params, data } = {}) => new Promise((resolve, reject) => { 26 | const request = superagent[method](formatUrl(path)); 27 | 28 | if (params) { 29 | request.query(params); 30 | } 31 | 32 | if (__SERVER__ && req.get('cookie')) { 33 | request.set('cookie', req.get('cookie')); 34 | } 35 | 36 | if (data) { 37 | request.send(data); 38 | } 39 | 40 | request.end((err, { body } = {}) => err ? reject(body || err) : resolve(body)); 41 | })); 42 | } 43 | } 44 | 45 | const ApiClient = _ApiClient; 46 | 47 | export default ApiClient; 48 | -------------------------------------------------------------------------------- /src/routes.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {IndexRoute, Route} from 'react-router'; 3 | import { isLoaded as isAuthLoaded, load as loadAuth } from 'redux/modules/auth'; 4 | import { 5 | App, 6 | Chat, 7 | Home, 8 | Widgets, 9 | About, 10 | Login, 11 | LoginSuccess, 12 | Survey, 13 | NotFound, 14 | } from 'containers'; 15 | 16 | export default (store) => { 17 | const requireLogin = (nextState, replaceState, cb) => { 18 | function checkAuth() { 19 | const { auth: { user }} = store.getState(); 20 | if (!user) { 21 | // oops, not logged in, so can't be here! 22 | replaceState(null, '/'); 23 | } 24 | cb(); 25 | } 26 | 27 | if (!isAuthLoaded(store.getState())) { 28 | store.dispatch(loadAuth()).then(checkAuth); 29 | } else { 30 | checkAuth(); 31 | } 32 | }; 33 | 34 | /** 35 | * Please keep routes in alphabetical order 36 | */ 37 | return ( 38 | 39 | { /* Home (main) route */ } 40 | 41 | 42 | { /* Routes requiring login */ } 43 | 44 | 45 | 46 | 47 | 48 | { /* Routes */ } 49 | 50 | 51 | 52 | 53 | 54 | { /* Catch all route */ } 55 | 56 | 57 | ); 58 | }; 59 | -------------------------------------------------------------------------------- /src/containers/Login/Login.js: -------------------------------------------------------------------------------- 1 | import React, {Component, PropTypes} from 'react'; 2 | import {connect} from 'react-redux'; 3 | import DocumentMeta from 'react-document-meta'; 4 | import * as authActions from 'redux/modules/auth'; 5 | 6 | @connect( 7 | state => ({user: state.auth.user}), 8 | authActions) 9 | export default class Login extends Component { 10 | static propTypes = { 11 | user: PropTypes.object, 12 | login: PropTypes.func, 13 | logout: PropTypes.func 14 | } 15 | 16 | handleSubmit(event) { 17 | event.preventDefault(); 18 | const input = this.refs.username; 19 | this.props.login(input.value); 20 | input.value = ''; 21 | } 22 | 23 | render() { 24 | const {user, logout} = this.props; 25 | const styles = require('./Login.scss'); 26 | return ( 27 |
28 | 29 |

Login

30 | {!user && 31 |
32 |
33 | 34 | 36 | 37 |

This will "log you in" as this user, storing the username in the session of the API server.

38 |
39 | } 40 | {user && 41 |
42 |

You are currently logged in as {user.name}.

43 | 44 |
45 | 46 |
47 |
48 | } 49 |
50 | ); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/redux/middleware/transitionMiddleware.js: -------------------------------------------------------------------------------- 1 | import {ROUTER_DID_CHANGE} from 'redux-router/lib/constants'; 2 | import getDataDependencies from '../../helpers/getDataDependencies'; 3 | 4 | const locationsAreEqual = (locA, locB) => (locA.pathname === locB.pathname) && (locA.search === locB.search); 5 | 6 | export default ({getState, dispatch}) => next => action => { 7 | if (action.type === ROUTER_DID_CHANGE) { 8 | if (getState().router && locationsAreEqual(action.payload.location, getState().router.location)) { 9 | return next(action); 10 | } 11 | 12 | const {components, location, params} = action.payload; 13 | const promise = new Promise((resolve) => { 14 | 15 | const doTransition = () => { 16 | next(action); 17 | Promise.all(getDataDependencies(components, getState, dispatch, location, params, true)) 18 | .then(resolve) 19 | .catch(error => { 20 | // TODO: You may want to handle errors for fetchDataDeferred here 21 | console.warn('Warning: Error in fetchDataDeferred', error); 22 | return resolve(); 23 | }); 24 | }; 25 | 26 | Promise.all(getDataDependencies(components, getState, dispatch, location, params)) 27 | .then(doTransition) 28 | .catch(error => { 29 | // TODO: You may want to handle errors for fetchData here 30 | console.warn('Warning: Error in fetchData', error); 31 | return doTransition(); 32 | }); 33 | }); 34 | 35 | if (__SERVER__) { 36 | // router state is null until ReduxRouter is created so we can use this to store 37 | // our promise to let the server know when it can render 38 | getState().router = promise; 39 | } 40 | 41 | return promise; 42 | } 43 | 44 | return next(action); 45 | }; 46 | -------------------------------------------------------------------------------- /src/components/__tests__/InfoBar-test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import {renderIntoDocument} from 'react-addons-test-utils'; 4 | import { expect} from 'chai'; 5 | import { InfoBar } from 'components'; 6 | import { Provider } from 'react-redux'; 7 | import {reduxReactRouter} from 'redux-router'; 8 | import createHistory from 'history/lib/createMemoryHistory'; 9 | import createStore from 'redux/create'; 10 | import ApiClient from 'helpers/ApiClient'; 11 | const client = new ApiClient(); 12 | 13 | describe('InfoBar', () => { 14 | const mockStore = { 15 | info: { 16 | load: () => {}, 17 | loaded: true, 18 | loading: false, 19 | data: { 20 | message: 'This came from the api server', 21 | time: Date.now() 22 | } 23 | } 24 | }; 25 | 26 | const store = createStore(reduxReactRouter, null, createHistory, client, mockStore); 27 | const renderer = renderIntoDocument( 28 | 29 | 30 | 31 | ); 32 | const dom = ReactDOM.findDOMNode(renderer); 33 | 34 | it('should render correctly', () => { 35 | return expect(renderer).to.be.ok; 36 | }); 37 | 38 | it('should render with correct value', () => { 39 | const text = dom.getElementsByTagName('strong')[0].textContent; 40 | expect(text).to.equal(mockStore.info.data.message); 41 | }); 42 | 43 | it('should render with a reload button', () => { 44 | const text = dom.getElementsByTagName('button')[0].textContent; 45 | expect(text).to.be.a('string'); 46 | }); 47 | 48 | it('should render the correct className', () => { 49 | const styles = require('components/InfoBar/InfoBar.scss'); 50 | expect(styles.infoBar).to.be.a('string'); 51 | expect(dom.className).to.include(styles.infoBar); 52 | }); 53 | 54 | }); 55 | -------------------------------------------------------------------------------- /src/containers/About/About.js: -------------------------------------------------------------------------------- 1 | import React, {Component} from 'react'; 2 | import DocumentMeta from 'react-document-meta'; 3 | import { MiniInfoBar } from 'components'; 4 | 5 | export default class About extends Component { 6 | state = { 7 | showKitten: false 8 | } 9 | 10 | handleToggleKitten() { 11 | this.setState({showKitten: !this.state.showKitten}); 12 | } 13 | 14 | render() { 15 | const {showKitten} = this.state; 16 | const kitten = require('./kitten.jpg'); 17 | return ( 18 |
19 |

About Us

20 | 21 | 22 |

This project was orginally created by Erik Rasmussen 23 | (@erikras), but has since seen many contributions 24 | from the open source community. Thank you to all the contributors. 27 |

28 | 29 |

Mini Bar (not that kind)

30 | 31 |

Hey! You found the mini info bar! The following component is display-only. Note that it shows the same 32 | time as the info bar.

33 | 34 | 35 | 36 |

Images

37 | 38 |

39 | Psst! Would you like to see a kitten? 40 | 41 | 45 |

46 | 47 | {showKitten &&
} 48 |
49 | ); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/theme/bootstrap.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Bootstrap configuration for bootstrap-sass-loader 3 | * 4 | * Scripts are disabled to not load jQuery. 5 | * If you depend on Bootstrap scripts consider react-bootstrap instead. 6 | * https://github.com/react-bootstrap/react-bootstrap 7 | * 8 | * In order to keep the bundle size low in production 9 | * disable components you don't use. 10 | * 11 | */ 12 | 13 | module.exports = { 14 | preBootstrapCustomizations: './src/theme/variables.scss', 15 | mainSass: './src/theme/bootstrap.overrides.scss', 16 | verbose: false, 17 | debug: false, 18 | scripts: { 19 | transition: false, 20 | alert: false, 21 | button: false, 22 | carousel: false, 23 | collapse: false, 24 | dropdown: false, 25 | modal: false, 26 | tooltip: false, 27 | popover: false, 28 | scrollspy: false, 29 | tab: false, 30 | affix: false 31 | }, 32 | styles: { 33 | mixins: true, 34 | normalize: true, 35 | print: true, 36 | glyphicons: true, 37 | scaffolding: true, 38 | type: true, 39 | code: true, 40 | grid: true, 41 | tables: true, 42 | forms: true, 43 | buttons: true, 44 | 'component-animations': true, 45 | dropdowns: true, 46 | 'button-groups': true, 47 | 'input-groups': true, 48 | navs: true, 49 | navbar: true, 50 | breadcrumbs: true, 51 | pagination: true, 52 | pager: true, 53 | labels: true, 54 | badges: true, 55 | jumbotron: true, 56 | thumbnails: true, 57 | alerts: true, 58 | 'progress-bars': true, 59 | media: true, 60 | 'list-group': true, 61 | panels: true, 62 | wells: true, 63 | 'responsive-embed': true, 64 | close: true, 65 | modals: true, 66 | tooltip: true, 67 | popovers: true, 68 | carousel: true, 69 | utilities: true, 70 | 'responsive-utilities': true 71 | } 72 | }; 73 | -------------------------------------------------------------------------------- /src/helpers/__tests__/getDataDependencies-test.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import React from 'react'; 3 | import { div } from 'react-dom'; 4 | import getDataDependencies from '../getDataDependencies'; 5 | 6 | describe('getDataDependencies', () => { 7 | let getState; 8 | let dispatch; 9 | let location; 10 | let params; 11 | let CompWithFetchData; 12 | let CompWithNoData; 13 | let CompWithFetchDataDeferred; 14 | const NullComponent = null; 15 | 16 | beforeEach(() => { 17 | getState = 'getState'; 18 | dispatch = 'dispatch'; 19 | location = 'location'; 20 | params = 'params'; 21 | 22 | CompWithNoData = () => 23 |
; 24 | 25 | CompWithFetchData = () => 26 |
; 27 | 28 | CompWithFetchData.fetchData = (_getState, _dispatch, _location, _params) => { 29 | return `fetchData ${_getState} ${_dispatch} ${_location} ${_params}`; 30 | }; 31 | CompWithFetchDataDeferred = () => 32 |
; 33 | 34 | CompWithFetchDataDeferred.fetchDataDeferred = (_getState, _dispatch, _location, _params) => { 35 | return `fetchDataDeferred ${_getState} ${_dispatch} ${_location} ${_params}`; 36 | }; 37 | }); 38 | 39 | it('should get fetchDatas', () => { 40 | const deps = getDataDependencies([ 41 | NullComponent, 42 | CompWithFetchData, 43 | CompWithNoData, 44 | CompWithFetchDataDeferred 45 | ], getState, dispatch, location, params); 46 | 47 | expect(deps).to.deep.equal([ 48 | 'fetchData getState dispatch location params' 49 | ]); 50 | }); 51 | 52 | it('should get fetchDataDeferreds', () => { 53 | const deps = getDataDependencies([ 54 | NullComponent, 55 | CompWithFetchData, 56 | CompWithNoData, 57 | CompWithFetchDataDeferred 58 | ], getState, dispatch, location, params, true); 59 | 60 | expect(deps).to.deep.equal([ 61 | 'fetchDataDeferred getState dispatch location params' 62 | ]); 63 | }); 64 | }); 65 | -------------------------------------------------------------------------------- /src/utils/validation.js: -------------------------------------------------------------------------------- 1 | const isEmpty = value => value === undefined || value === null || value === ''; 2 | const join = (rules) => (value, data) => rules.map(rule => rule(value, data)).filter(error => !!error)[0 /* first error */ ]; 3 | 4 | export function email(value) { 5 | // Let's not start a debate on email regex. This is just for an example app! 6 | if (!isEmpty(value) && !/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,4}$/i.test(value)) { 7 | return 'Invalid email address'; 8 | } 9 | } 10 | 11 | export function required(value) { 12 | if (isEmpty(value)) { 13 | return 'Required'; 14 | } 15 | } 16 | 17 | export function minLength(min) { 18 | return value => { 19 | if (!isEmpty(value) && value.length < min) { 20 | return `Must be at least ${min} characters`; 21 | } 22 | }; 23 | } 24 | 25 | export function maxLength(max) { 26 | return value => { 27 | if (!isEmpty(value) && value.length > max) { 28 | return `Must be no more than ${max} characters`; 29 | } 30 | }; 31 | } 32 | 33 | export function integer(value) { 34 | if (!Number.isInteger(Number(value))) { 35 | return 'Must be an integer'; 36 | } 37 | } 38 | 39 | export function oneOf(enumeration) { 40 | return value => { 41 | if (!~enumeration.indexOf(value)) { 42 | return `Must be one of: ${enumeration.join(', ')}`; 43 | } 44 | }; 45 | } 46 | 47 | export function match(field) { 48 | return (value, data) => { 49 | if (data) { 50 | if (value !== data[field]) { 51 | return 'Do not match'; 52 | } 53 | } 54 | }; 55 | } 56 | 57 | export function createValidator(rules) { 58 | return (data = {}) => { 59 | const errors = {}; 60 | Object.keys(rules).forEach((key) => { 61 | const rule = join([].concat(rules[key])); // concat enables both functions and arrays of functions 62 | const error = rule(data[key], data); 63 | if (error) { 64 | errors[key] = error; 65 | } 66 | }); 67 | return errors; 68 | }; 69 | } 70 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | var webpack = require('webpack'); 2 | 3 | module.exports = function (config) { 4 | config.set({ 5 | 6 | browsers: ['PhantomJS'], 7 | 8 | singleRun: !!process.env.CONTINUOUS_INTEGRATION, 9 | 10 | frameworks: [ 'mocha' ], 11 | 12 | files: [ 13 | './node_modules/phantomjs-polyfill/bind-polyfill.js', 14 | 'tests.webpack.js' 15 | ], 16 | 17 | preprocessors: { 18 | 'tests.webpack.js': [ 'webpack', 'sourcemap' ] 19 | }, 20 | 21 | reporters: [ 'mocha' ], 22 | 23 | plugins: [ 24 | require("karma-webpack"), 25 | require("karma-mocha"), 26 | require("karma-mocha-reporter"), 27 | require("karma-phantomjs-launcher"), 28 | require("karma-sourcemap-loader") 29 | ], 30 | 31 | webpack: { 32 | devtool: 'inline-source-map', 33 | module: { 34 | loaders: [ 35 | { test: /\.(jpe?g|png|gif|svg)$/, loader: 'url', query: {limit: 10240} }, 36 | { test: /\.js$/, exclude: /node_modules/, loaders: ['babel']}, 37 | { test: /\.json$/, loader: 'json-loader' }, 38 | { test: /\.less$/, loader: 'style!css!less' }, 39 | { test: /\.scss$/, loader: 'style!css?modules&importLoaders=2&sourceMap&localIdentName=[local]___[hash:base64:5]!autoprefixer?browsers=last 2 version!sass?outputStyle=expanded&sourceMap' } 40 | ] 41 | }, 42 | resolve: { 43 | modulesDirectories: [ 44 | 'src', 45 | 'node_modules' 46 | ], 47 | extensions: ['', '.json', '.js'] 48 | }, 49 | plugins: [ 50 | new webpack.IgnorePlugin(/\.json$/), 51 | new webpack.NoErrorsPlugin(), 52 | new webpack.DefinePlugin({ 53 | __CLIENT__: true, 54 | __SERVER__: false, 55 | __DEVELOPMENT__: true, 56 | __DEVTOOLS__: false // <-------- DISABLE redux-devtools HERE 57 | }) 58 | ] 59 | }, 60 | 61 | webpackServer: { 62 | noInfo: true 63 | } 64 | 65 | }); 66 | }; 67 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guidelines 2 | 3 | Some basic conventions for this project. 4 | 5 | ### General 6 | 7 | Please make sure that there aren't existing pull requests attempting to address the issue mentioned. Likewise, please check for issues related to update, as someone else may be working on the issue in a branch or fork. 8 | 9 | * Non-trivial changes should be discussed in an issue first 10 | * Develop in a topic branch, not master 11 | * Squash your commits 12 | 13 | ### Linting 14 | 15 | Please check your code using `eslint` before submitting your pull requests, as the CI build will fail if `eslint` fails. 16 | 17 | ### Commit Message Format 18 | 19 | Each commit message should include a **type**, a **scope** and a **subject**: 20 | 21 | ``` 22 | (): 23 | ``` 24 | 25 | Lines should not exceed 100 characters. This allows the message to be easier to read on github as well as in various git tools and produces a nice, neat commit log ie: 26 | 27 | ``` 28 | #459 refactor(utils): create url mapper utility function 29 | #463 chore(webpack): update to isomorphic tools v2 30 | #494 fix(babel): correct dependencies and polyfills 31 | #510 feat(app): add react-bootstrap responsive navbar 32 | ``` 33 | 34 | #### Type 35 | 36 | Must be one of the following: 37 | 38 | * **feat**: A new feature 39 | * **fix**: A bug fix 40 | * **docs**: Documentation only changes 41 | * **style**: Changes that do not affect the meaning of the code (white-space, formatting, missing 42 | semi-colons, etc) 43 | * **refactor**: A code change that neither fixes a bug or adds a feature 44 | * **test**: Adding missing tests 45 | * **chore**: Changes to the build process or auxiliary tools and libraries such as documentation 46 | generation 47 | 48 | #### Scope 49 | 50 | The scope could be anything specifying place of the commit change. For example `webpack`, 51 | `helpers`, `api` etc... 52 | 53 | #### Subject 54 | 55 | The subject contains succinct description of the change: 56 | 57 | * use the imperative, present tense: "change" not "changed" nor "changes" 58 | * don't capitalize first letter 59 | * no dot (.) at the end 60 | -------------------------------------------------------------------------------- /src/client.js: -------------------------------------------------------------------------------- 1 | /** 2 | * THIS IS THE ENTRY POINT FOR THE CLIENT, JUST LIKE server.js IS THE ENTRY POINT FOR THE SERVER. 3 | */ 4 | import 'babel/polyfill'; 5 | import React from 'react'; 6 | import ReactDOM from 'react-dom'; 7 | import createHistory from 'history/lib/createBrowserHistory'; 8 | import createStore from './redux/create'; 9 | import ApiClient from './helpers/ApiClient'; 10 | import io from 'socket.io-client'; 11 | import {Provider} from 'react-redux'; 12 | import {reduxReactRouter, ReduxRouter} from 'redux-router'; 13 | 14 | import getRoutes from './routes'; 15 | import makeRouteHooksSafe from './helpers/makeRouteHooksSafe'; 16 | 17 | const client = new ApiClient(); 18 | 19 | const dest = document.getElementById('content'); 20 | const store = createStore(reduxReactRouter, makeRouteHooksSafe(getRoutes), createHistory, client, window.__data); 21 | 22 | function initSocket() { 23 | const socket = io('', {path: '/api/ws', transports: ['polling']}); 24 | socket.on('news', (data) => { 25 | console.log(data); 26 | socket.emit('my other event', { my: 'data from client' }); 27 | }); 28 | socket.on('msg', (data) => { 29 | console.log(data); 30 | }); 31 | 32 | return socket; 33 | } 34 | 35 | global.socket = initSocket(); 36 | 37 | const component = ( 38 | 39 | ); 40 | 41 | ReactDOM.render( 42 | 43 | {component} 44 | , 45 | dest 46 | ); 47 | 48 | if (process.env.NODE_ENV !== 'production') { 49 | window.React = React; // enable debugger 50 | 51 | if (!dest || !dest.firstChild || !dest.firstChild.attributes || !dest.firstChild.attributes['data-react-checksum']) { 52 | console.error('Server-side React render was discarded. Make sure that your initial render does not contain any client-side code.'); 53 | } 54 | } 55 | 56 | if (__DEVTOOLS__) { 57 | const DevTools = require('./containers/DevTools/DevTools'); 58 | ReactDOM.render( 59 | 60 |
61 | {component} 62 | 63 |
64 |
, 65 | dest 66 | ); 67 | } 68 | -------------------------------------------------------------------------------- /src/containers/Chat/Chat.js: -------------------------------------------------------------------------------- 1 | import React, {Component, PropTypes} from 'react'; 2 | import {connect} from 'react-redux'; 3 | 4 | @connect( 5 | state => ({user: state.auth.user}) 6 | ) 7 | export default 8 | class Chat extends Component { 9 | static propTypes = { 10 | user: PropTypes.object 11 | }; 12 | 13 | state = { 14 | message: '', 15 | messages: [] 16 | }; 17 | 18 | componentDidMount() { 19 | if (socket && !this.onMsgListener) { 20 | this.onMsgListener = socket.on('msg', this.onMessageReceived.bind(this)); 21 | 22 | setTimeout(() => { 23 | socket.emit('history', {offset: 0, length: 100}); 24 | }, 100); 25 | } 26 | } 27 | 28 | componentWillUnmount() { 29 | if (socket && this.onMsgListener) { 30 | socket.removeListener('on', this.onMsgListener); 31 | this.onMsgListener = null; 32 | } 33 | } 34 | 35 | onMessageReceived(data) { 36 | const messages = this.state.messages; 37 | messages.push(data); 38 | this.setState({messages}); 39 | } 40 | 41 | handleSubmit(event) { 42 | event.preventDefault(); 43 | 44 | const msg = this.state.message; 45 | 46 | this.setState({message: ''}); 47 | 48 | socket.emit('msg', { 49 | from: this.props.user.name, 50 | text: msg 51 | }); 52 | } 53 | 54 | render() { 55 | const style = require('./Chat.scss'); 56 | const {user} = this.props; 57 | 58 | return ( 59 |
60 |

Chat

61 | 62 | {user && 63 |
64 |
    65 | {this.state.messages.map((msg) => { 66 | return
  • {msg.from}: {msg.text}
  • ; 67 | })} 68 |
69 |
70 | { 73 | this.setState({message: event.target.value}); 74 | } 75 | }/> 76 | 77 |
78 |
79 | } 80 |
81 | ); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /api/__tests__/api-test.js: -------------------------------------------------------------------------------- 1 | import {expect} from 'chai'; 2 | import {mapUrl} from '../utils/url'; 3 | 4 | describe('mapUrl', () => { 5 | 6 | it('extracts nothing if both params are undefined', () => { 7 | expect(mapUrl(undefined, undefined)).to.deep.equal({ 8 | action: null, 9 | params: [] 10 | }); 11 | }); 12 | 13 | it('extracts nothing if the url is empty', () => { 14 | const url = ''; 15 | const splittedUrlPath = url.split('?')[0].split('/').slice(1); 16 | const availableActions = {a: 1, widget: {c: 1, load: () => 'baz'}}; 17 | 18 | expect(mapUrl(availableActions, splittedUrlPath)).to.deep.equal({ 19 | action: null, 20 | params: [] 21 | }); 22 | }); 23 | 24 | it('extracts nothing if nothing was found', () => { 25 | const url = '/widget/load/?foo=bar'; 26 | const splittedUrlPath = url.split('?')[0].split('/').slice(1); 27 | const availableActions = {a: 1, info: {c: 1, load: () => 'baz'}}; 28 | 29 | expect(mapUrl(availableActions, splittedUrlPath)).to.deep.equal({ 30 | action: null, 31 | params: [] 32 | }); 33 | }); 34 | it('extracts the available actions and the params from an relative url string with GET params', () => { 35 | 36 | const url = '/widget/load/param1/xzy?foo=bar'; 37 | const splittedUrlPath = url.split('?')[0].split('/').slice(1); 38 | const availableActions = {a: 1, widget: {c: 1, load: () => 'baz'}}; 39 | 40 | expect(mapUrl(availableActions, splittedUrlPath)).to.deep.equal({ 41 | action: availableActions.widget.load, 42 | params: ['param1', 'xzy'] 43 | }); 44 | }); 45 | 46 | it('extracts the available actions from an url string without GET params', () => { 47 | const url = '/widget/load/?foo=bar'; 48 | const splittedUrlPath = url.split('?')[0].split('/').slice(1); 49 | const availableActions = {a: 1, widget: {c: 1, load: () => 'baz'}}; 50 | 51 | expect(mapUrl(availableActions, splittedUrlPath)).to.deep.equal({ 52 | action: availableActions.widget.load, 53 | params: [''] 54 | }); 55 | }); 56 | 57 | it('does not find the avaialble action if deeper nesting is required', () => { 58 | const url = '/widget'; 59 | const splittedUrlPath = url.split('?')[0].split('/').slice(1); 60 | const availableActions = {a: 1, widget: {c: 1, load: () => 'baz'}}; 61 | 62 | expect(mapUrl(availableActions, splittedUrlPath)).to.deep.equal({ 63 | action: null, 64 | params: [] 65 | }); 66 | }); 67 | }); 68 | -------------------------------------------------------------------------------- /src/helpers/Html.js: -------------------------------------------------------------------------------- 1 | import React, {Component, PropTypes} from 'react'; 2 | import ReactDOM from 'react-dom/server'; 3 | import serialize from 'serialize-javascript'; 4 | import DocumentMeta from 'react-document-meta'; 5 | 6 | /** 7 | * Wrapper component containing HTML metadata and boilerplate tags. 8 | * Used in server-side code only to wrap the string output of the 9 | * rendered route component. 10 | * 11 | * The only thing this component doesn't (and can't) include is the 12 | * HTML doctype declaration, which is added to the rendered output 13 | * by the server.js file. 14 | */ 15 | export default class Html extends Component { 16 | static propTypes = { 17 | assets: PropTypes.object, 18 | component: PropTypes.node, 19 | store: PropTypes.object 20 | } 21 | 22 | render() { 23 | const {assets, component, store} = this.props; 24 | const content = component ? ReactDOM.renderToString(component) : ''; 25 | 26 | return ( 27 | 28 | 29 | {DocumentMeta.renderAsReact()} 30 | 31 | 32 | 33 | {/* styles (will be present only in production with webpack extract text plugin) */} 34 | {Object.keys(assets.styles).map((style, key) => 35 | 37 | )} 38 | 39 | {/* (will be present only in development mode) */} 40 | {/* outputs a