Redux quick start
Create fixture for testing
Create a reducer
Create a service
Create an action
Create a component
Create a store
Create actions for App
Create a container
Create an App
Constants and helpers
Here we go
Background
- Switching from OOP to FP is difficult to some degree.
- Implementing Redux requires tons of NPM package that you need to know.
- Lacking demo.
Project structure
Theory: view is the reflection of data, for every changes of the data would refresh the view and display the latest status. I think this is the fundamental theory of Flux.
I once built an app with Backbone.Marionette with the idea of this. And found that I could cut off tons of logics and DOM manipulation. As the app grows healthier with continuously refactor, the structure of the App become so much like another React based framework MobX(kind of an OOP framework). With this practice, I could well understand the Redux structure with the same theory, even though it’s not an OOP framework.
In this structure, it divides the whole project into two levels: App level and the component level. Each app contains a Store, an Action and at least a Container.
App level:
Store: to initialize tons of reducers from reducer pool with initiated data.
Action: contains tons of actions from the Action pool.
Container: contains tons of components from component pool.
Component level(library):
Reducers, Actions, Components, Constants, Helpers(Utilities), Services.
Command line:
npm initThen we could build the folders like 3–3
Commit on Github
Start with Webpack
NPM packages that you may need to know
Command line:npm install —-save-dev webpack babel-core babel-loader babel-plugin-webpack-alias babel-preset-es2015 babel-preset-react
Or update the package.json and run
npm installAlso, you need to append the babel settings to it. Also, create a build:dev command for the project.
./package.json
"scripts": {
"test": "test",
"build:dev": "NODE_ENV=dev webpack --config webpack.dev.config.js"
},
"devDependencies": {
"babel-core": "^6.25.0",
"babel-loader": "^7.1.1",
"babel-plugin-webpack-alias": "^2.1.2",
"babel-preset-es2015": "^6.24.1", // for compiling into ES2015
"babel-preset-react": "^6.24.1", // for React JSX syntax
"webpack": "^3.4.1"
},
"babel": {
"presets": [
"es2015",
"react"
]
}Webpack set ups
./webpack.dev.config.js
var webpack = require('webpack');
var path = require('path');
module.exports = {
devtool: 'inline-source-map',
entry: {
'got': './src/app/got/index.jsx'
},
module: {
rules: [
{
test: /\.jsx?$/,
exclude: /(node_modules|_spec\.jsx)/,
use: ['babel-loader']
}
]
},
resolve: {
enforceExtension: false,
extensions: ['.js', '.jsx']
},
output: {
path: __dirname + '/dist',
publicPath: '/',
filename: '[name]/[name].js'
}
};And now you can run npm run build:dev. It will generate the file that we have.

Commit on Github
Config for Dev server
And now we could set the config for the dev server.
NPM packages:
First, install the package:
Command line:npm install --save-dev webpack-dev-server html-webpack-plugin
Then, create a script to run the start command:
./package.json
"scripts": {
"test": "test",
"build:dev": "NODE_ENV=dev webpack --config webpack.dev.config.js",
"start": "NODE_ENV=dev webpack-dev-server --config webpack.dev.config.js"
}also, append the settings for webpack-dev-server in the webpack config
./webpack.dev.config.js
devServer: {
contentBase: './dist',
hot: true,
port: 3000,
historyApiFallback: true,
disableHostCheck: true
}For now, you can start the server with:
npm run startVisiting the localhost:3000/got/got.js to take a look.
Now, we need a html template for our app. Start with creating a template file.
./src/templates/index.tpl.ejs
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="<%= htmlWebpackPlugin.files.css[0] %>"></link>
</head>
<body>
<div >
<div class="container">
<div id="<%= htmlWebpackPlugin.options.appRoot %>"></div>
</div>
</div>
<% for(var i=0; i < htmlWebpackPlugin.files.js.length; i++) { %>
<script type="text/javascript" src="<%= htmlWebpackPlugin.files.js[i] %>"></script>
<% } %>
</body>
</html>Also update the settings in
./webpack.dev.config.js
var webpack = require('webpack');
var path = require('path');
var HtmlWebpackPlugin = require('html-webpack-plugin');
...
module.exports = {...
plugins: [
new webpack.HotModuleReplacementPlugin(),
new HtmlWebpackPlugin({
template: 'src/templates/index.tpl.ejs',
chunks: ['got'],
appRoot: 'app-container',
filename: 'got/index.html',
inject: false
})
]
...
}
Now, you can run the command line to compile the files.
npm run build:devThen visit the http://localhost:3000/got to take a look. (Since we are separating the build and start process, we don’t have to restart the server here)
Commit on Github
Unit testing configuration
NPM packages:
Install packages with npm:
npm install —-save-dev react react-test-renderer react-dom enzyme chai sinon-chai sinon jsdom@9.9.1 mochaWhy do we need three packages instead of one? What are they for? This practice may help you understand the responsibility of each package.
Now we could try this with React. First, we need React. We need jsdom as well to fake a document.
Then, create a setup file.
./setup.js
require('babel-core/register')();
var jsdom = require('jsdom').jsdom;
var exposedProperties = ['window', 'navigator', 'document'];
global.document = jsdom('');
global.window = document.defaultView;
Object.keys(document.defaultView).forEach((property) => {
if (typeof global[property] === 'undefined') {
exposedProperties.push(property);
global[property] = document.defaultView[property];
}
});
global.navigator = {
userAgent: 'node.js'
};
documentRef = document;update the test command of npm test
./package.json
"test": "NODE_ENV=test mocha ./.setup.js src/**/**/**/*spec.js* src/**/**/*spec.js* src/**/*spec.js*",Create a test file
./src/app/got/spec.jsx
import React from 'react';
import { mount} from 'enzyme';
import chai, { expect } from 'chai';
import sinonChai from 'sinon-chai';
import sinon from 'sinon';
chai.use(sinonChai);
describe('test', () => {
it('test input', () => {
const spy = sinon.spy();
const view = mount(<input type="text" onChange={spy}/>);
view.find('input').simulate('change', { target: { value: 'test' } });
expect(spy).to.have.been.calledOnce;
})
});Finally, run command line
npm testAnd there will be a report of the test
test
✓ test input1 passing (345ms)
There are many test frameworks for React. And you can switch any part of it into another as you wish. Mocha is the basic framework, while enzyme take charge of the inserting the view in the test document, either shallow render(in memory) or mount render, as well as the simulation. What sinon does is to provide spy. And chai is the assertion library.

Notice that we are using the specific version of jsdom here, I encountered some error that cannot be solved with the latest version. Will update here when I can figure it out.
Commit on Github
Create fixture for testing
Since we should stick to TDD process, we need to create fixture for the test first.
Usually we should create the fixture data which can cover most of the situations.
export const Father = {
id: 1,
firstName: 'Ned',
lastName: 'Stark',
gender: 'male',
house: 'Stark',
isAlive: false,
father: undefined,
mother: undefined,
siblings: [],
children: [3]
};
export const Mother = {
id: 2,
firstName: 'Catelyn',
lastName: 'Stark',
gender: 'female',
house: 'Stark',
isAlive: false,
father: undefined,
mother: undefined,
siblings: [],
children: [3]
};
export const Child = {
id: 3,
firstName: 'Arya',
lastName: 'Stark',
gender: 'female',
house: 'Stark',
isAlive: true,
father: 1,
mother: 2,
siblings: [],
children: []
};
export const Characters = [
Father, Mother, Child
];Commit on Github
Create a reducer
NPM packages:
Immutable provides a lot of functions that could help with dealing with the data.
Since we are going to import from different folders, we are going to update the resolves of the Webpack settings.
./webpack.dev.config.js
resolve: {
enforceExtension: false,
extensions: ['.js', '.jsx'],
alias: {
Fixtures: path.resolve(__dirname, 'src/fixtures'),
Reducers: path.resolve(__dirname, 'src/reducers'),
Constants: path.resolve(__dirname, 'src/constants'),
}
},with this changes, we could import files like:
// import { actions } from '../../../constants/characters';
import { actions } from 'Constants/characters';You may also need to update the test command to fix the alias for testing.
./package.json
"test": "NODE_ENV=test mocha ./.setup.js src/**/**/**/*spec.js* src/**/**/*spec.jsx src/**/*spec.js*",Create action names as constants:
import mirrorCreator from 'mirror-creator';
export const actions = mirrorCreator([
'CREATE_CHARACTER',
'REMOVE_CHARACTER',
'KILL_CHARACTER',
'BRING_BACK_CHARACTER'
]);Create a spec file for the unit testing:
./src/reducers/characters/spec.jsx
import { expect } from 'chai';
import { fromJS } from 'immutable';
import reducers from './index';
import { actions } from 'Constants/characters';
import { Characters, Father, Mother, Child } from 'Fixtures/characters/index';
describe('characters reducers', () => {
describe('default', () => {
it('should return default state', () => {
const action = {
type: 'TEST'
};
expect(reducers(undefined, action)).to.eql([]);
});
});
describe('CREATE_CHARACTER', () => {
it('should return new state', () => {
const action = {
type: actions.CREATE_CHARACTER,
data: Father
};
expect(reducers(undefined, action)).to.eql([Father]);
});
});
describe('REMOVE_CHARACTER', () => {
it('should return new state', () => {
const action = {
type: actions.REMOVE_CHARACTER,
data: Father
};
expect(reducers(Characters, action)).to.eql([Mother, Child]);
});
});
describe('KILL_CHARACTER', () => {
it('should return new state', () => {
const action = {
type: actions.KILL_CHARACTER,
data: Child
};
const expectation = fromJS(Child).set('isAlive', false).toJS();
expect(reducers(Characters, action)[2]).to.eql(expectation);
});
});
describe('BRING_BACK_CHARACTER', () => {
it('should return new state', () => {
const action = {
type: actions.BRING_BACK_CHARACTER,
data: Father
};
const expectation = fromJS(Father).set('isAlive', true).toJS();
expect(reducers(Characters, action)[0]).to.eql(expectation);
});
});
});Finally you need to create a reducer, begin with an init state.
./src/reducers/characters/index.jsx
import { fromJS, Map } from 'immutable';
import { createReducer } from 'redux-create-reducer';
import { actions } from 'Constants/characters';
export const initState = [];
function CREATE_CHARACTER(state, action) {
const characters = fromJS(state);
return characters.push(Map(action.data)).toJS();
}
function KILL_CHARACTER(state, action) {
const characters = fromJS(state);
const index = characters.findIndex((item) => {
return item.get('id') === action.data.id;
});
return characters.update(index, (character) => {
return character.set('isAlive', false);
}).toJS()
}
function REMOVE_CHARACTER(state, action) {
const characters = fromJS(state);
return characters.filter((item) => {
return item.get('id') != action.data.id;
}).toJS();
}
function BRING_BACK_CHARACTER(state, action) {
const characters = fromJS(state);
const index = characters.findIndex((item) => {
return item.get('id') === action.data.id;
});
return characters.update(index, (character) => {
return character.set('isAlive', true);
}).toJS();
}
function UPDATE_CHARACTER(state, action) {
const characters = fromJS(state);
const index = characters.findIndex((item) => {
return item.get('id') === action.data.id;
});
return characters.update(index, (character) => {
return character.set(action.data);
}).toJS()
}
export default createReducer(initState, {
[actions.CREATE_CHARACTER]: CREATE_CHARACTER,
[actions.REMOVE_CHARACTER]: REMOVE_CHARACTER,
[actions.KILL_CHARACTER]: KILL_CHARACTER,
[actions.BRING_BACK_CHARACTER]: BRING_BACK_CHARACTER,
[actions.UPDATE_CHARACTER]: UPDATE_CHARACTER
})And now, your first reducer is online.
Commit on Github
Create a service
NPM packages:
Install with command line
npm install --save-dev jquery query-stringCreate alias for services folder:
./webpack.dev.config.js
Services: path.resolve(__dirname, ‘src/services’),We need to create the base library for the services to return the header and base uri.
create constants for the URI.
./src/constants/services/index.jsx
export const ServiceConstants = {
BASE_URL: 'http://localhost:3000',
CHARACTER_ROUTE: '/character'
};./src/services/lib/spec.jsx
import { expect } from 'chai';
import { getBasicUrl, getRequestHeader } from './index';
describe('service lib', () => {
describe('getBasicUrl', () => {
it('should return correct base request url', () => {
expect(getBasicUrl()).to.eqls('http://localhost:3000');
})
});
describe('getBasicUrl', () => {
it('should return correct base request url', () => {
expect(getRequestHeader()).to.eqls({
Accept: "application/json",
"Content-Type": "application/json"
});
})
});
});If the test giving error, you may also need to update the .setup.js
./.setup.js
global.HTMLElement = window.HTMLElement;./src/services/lib/index.jsx
import { ServiceConstants } from 'Constants/services';
export function getBasicUrl() {
return ServiceConstants.BASE_URL;
}
export function getRequestHeader() {
return {
Accept: "application/json",
"Content-Type": "application/json"
}
}and then create the character services
./src/services/characters/spec.jsx
import chai, { expect } from "chai";
import sinon from "sinon";
import sinonChai from "sinon-chai";
import jQuery from "jquery"
import * as client from "./index";
import { Father } from 'Fixtures/characters';
chai.use(sinonChai);
describe("client", () => {
beforeEach(() => {
sinon.stub(jQuery, "ajax").returns({
done: () => {}
})
});
afterEach(() => {
jQuery.ajax.restore()
});
describe('post', () => {
it('should have correct request', () => {
client.post('/test', Father);
expect(jQuery.ajax).to.have.been.calledWithMatch({
method: 'POST',
uri: 'http://localhost:3000/test',
data: Father,
json: true,
header: {
Accept: "application/json",
"Content-Type": "application/json"
}
});
});
});
describe('fetch', () => {
it('should have correct request', () => {
client.fetch('/test', {query: 'testquery'});
expect(jQuery.ajax).to.have.been.calledWithMatch({
method: 'GET',
uri: 'http://localhost:3000/test?query=testquery',
json: true,
header: {
Accept: "application/json",
"Content-Type": "application/json"
}
});
});
});
describe('update', () => {
it('should have correct request', () => {
client.update('/test', Father);
expect(jQuery.ajax).to.have.been.calledWithMatch({
method: 'PUT',
uri: 'http://localhost:3000/test',
data: Father,
json: true,
header: {
Accept: "application/json",
"Content-Type": "application/json"
}
});
});
});
describe('destroy', () => {
it('should have correct request', () => {
client.destroy('/test', Father);
expect(jQuery.ajax).to.have.been.calledWithMatch({
method: 'DELETE',
uri: 'http://localhost:3000/test',
header: {
Accept: "application/json",
"Content-Type": "application/json"
}
});
});
});
});./src/services/characters/index.jsx
import * as queryString from 'query-string';
import $ from "jquery";
import { getBasicUrl, getRequestHeader } from 'Services/lib';
export function post(route, data) {
return $.ajax({
method: 'POST',
uri: `${getBasicUrl()}${route}`,
data: data,
json: true,
header: getRequestHeader()
})
}
export function fetch(route, queryObject = {}) {
return $.ajax({
method: 'GET',
uri: `${getBasicUrl()}${route}?${queryString.stringify(queryObject)}`,
header: getRequestHeader(),
json: true,
})
}
export function update(route, data) {
return $.ajax({
method: 'PUT',
uri: `${getBasicUrl()}${route}`,
data: data,
json: true,
header: getRequestHeader()
})
}
export function destroy(route){
return $.ajax({
method: 'DELETE',
uri: `${getBasicUrl()}${route}`,
header: getRequestHeader()
})
}Commit on Github
Create an action
These are only functions, so we don’t have to import any other packages.
Begin with the test.
./src/actions/characters/spec.jsx
import $ from 'jquery';
import { expect } from 'chai';
import sinon from 'sinon';
import { actions } from 'Constants/characters';
import * as services from 'Services/characters';
import * as characterActions from './index';
import { Characters, Father, Mother, Child } from 'Fixtures/characters';
describe('character actions', () => {
let dispatchSpy, getStateSpy, mockPromise;
describe('create a character', () => {
beforeEach(() => {
dispatchSpy = sinon.spy();
getStateSpy = sinon.stub().returns(Father);
mockPromise = $.Deferred();
sinon.stub(services, 'post').returns(mockPromise);
characterActions.create()(dispatchSpy, getStateSpy)
});
afterEach(() => {
services.post.restore();
});
it(`should dispatch a ${actions.CREATE_CHARACTER} event`, () => {
mockPromise.resolve(Father);
sinon.assert.calledWith(dispatchSpy, {
type: actions.CREATE_CHARACTER,
data: Father
});
})
});
describe('remove a character', () => {
beforeEach(() => {
dispatchSpy = sinon.spy();
getStateSpy = sinon.stub().returns(Father);
mockPromise = $.Deferred();
sinon.stub(services, 'destroy').returns(mockPromise);
characterActions.remove(Father)(dispatchSpy, getStateSpy)
});
afterEach(() => {
services.destroy.restore();
});
it(`should dispatch a ${actions.REMOVE_CHARACTER} event`, () => {
mockPromise.resolve({});
sinon.assert.calledWith(dispatchSpy, {
type: actions.REMOVE_CHARACTER,
data: {}
});
})
});
describe('kill a character', () => {
beforeEach(() => {
dispatchSpy = sinon.spy();
getStateSpy = sinon.stub().returns(Father);
mockPromise = $.Deferred();
sinon.stub(services, 'update').returns(mockPromise);
characterActions.kill(Father)(dispatchSpy, getStateSpy)
});
afterEach(() => {
services.update.restore();
});
it(`should dispatch a ${actions.KILL_CHARACTER} event`, () => {
mockPromise.resolve(Father);
sinon.assert.calledWith(dispatchSpy, {
type: actions.KILL_CHARACTER,
data: Father
});
})
});
describe('rebirth a character', () => {
beforeEach(() => {
dispatchSpy = sinon.spy();
getStateSpy = sinon.stub().returns(Father);
mockPromise = $.Deferred();
sinon.stub(services, 'update').returns(mockPromise);
characterActions.rebirth(Father)(dispatchSpy, getStateSpy)
});
afterEach(() => {
services.update.restore();
});
it(`should dispatch a ${actions.BRING_BACK_CHARACTER} event`, () => {
mockPromise.resolve(Father);
sinon.assert.calledWith(dispatchSpy, {
type: actions.BRING_BACK_CHARACTER,
data: Father
});
})
});
});Then to implement the codes.
./src/actions/characters/index.jsx
import { actions } from 'Constants/characters';
import * as services from 'Services/characters';
export function create(character) {
return (dispatch, getState) => {
return services.post('/character', character).done((response) => {
dispatch({
type: actions.CREATE_CHARACTER,
data: response
});
});
}
}
export function remove(character) {
return (dispatch, getState) => {
return services.destroy(`/character/${character.id}`).done((response) => {
dispatch({
type: actions.REMOVE_CHARACTER,
data: response
});
});
}
}
export function kill(character) {
return (dispatch, getState) => {
return services.update(`/character/${character.id}`, character).done((response) => {
dispatch({
type: actions.KILL_CHARACTER,
data: response
});
});
}
}
export function rebirth(character) {
return (dispatch, getState) => {
return services.update(`/character/${character.id}`, character).done((response) => {
dispatch({
type: actions.BRING_BACK_CHARACTER,
data: response
});
});
}
}Commit on Github
Create a component
So now, we are going to create a character list and have a create character form at the end of the list. Also, we allow the user to update the character information of the character. Since we need to display some information out of the character, we are going to separated the HOUSE and the CHARACTER.
First, create fixture for the unit test.
./src/fixtures/houses.jsx
export const Stark = {
id: 1,
name: 'Stark',
hostId: 1,
city: 'Winterfell'
};
export const Targaryen = {
id: 2,
name: 'Targaryen',
hostId: 0,
city: 'Dragon Stone'
};
export const Houses = [
Stark, Targaryen
];Also, updating the fixture of characters.
./src/fixtures/characters/index.jsx
export const Father = {
id: 1,
firstName: 'Ned',
lastName: 'Stark',
gender: 'male',
houseId: 1,
isAlive: false,
father: undefined,
mother: undefined,
siblings: [],
children: [3]
};
export const Mother = {
id: 2,
firstName: 'Catelyn',
lastName: 'Stark',
gender: 'female',
houseId: 1,
isAlive: false,
father: undefined,
mother: undefined,
siblings: [],
children: [3]
};
export const Child = {
id: 3,
firstName: 'Arya',
lastName: 'Stark',
gender: 'female',
houseId: 1,
isAlive: true,
father: 1,
mother: 2,
siblings: [],
children: []
};
export const Characters = [
Father, Mother, Child
];We would just begin with the create form. We are not going to implement the edit and delete function currently.
./src/components/characters/create/spec.jsx
import React from 'react';
import { mount } from 'enzyme';
import chai, { expect } from 'chai';
import sinon from 'sinon';
import sinonChai from 'sinon-chai';
chai.use(sinonChai);
import { CreateCharacter } from './index';
import { Houses } from 'Fixtures/houses';
describe('CreateCharacter', () => {
const defaultProps = {
houses: Houses
};
const subject = (props) => {
const combinedProps = Object.assign({}, defaultProps, props);
return mount(
<CreateCharacter {...combinedProps}/>
)
};
describe('rendering', () => {
it('should have inputs', () => {
const spy = sinon.spy();
const component = subject({createCharacter: spy});
expect(component.find('[name="firstName"]').length).to.eqls(1);
expect(component.find('[name="lastName"]').length).to.eqls(1);
expect(component.find('[name="gender"]').length).to.eqls(2);
expect(component.find('select').length).to.eqls(1);
expect(component.find('[type="submit"]').length).to.eqls(1);
});
});
describe('submit form', () => {
it('should call createCharacter when submit', () => {
const spy = sinon.spy();
const component = subject({createCharacter: spy});
component.find('form').simulate('submit');
expect(spy).to.have.callCount(1);
});
});
});./src/components/characters/create/index.jsx
import React, { PropTypes } from 'react';
export class CreateCharacter extends React.Component {
constructor(options) {
super(options);
this.state = Object.assign({}, this.initState(), this.props);
}
initState() {
return {
firstName: '',
lastName: '',
id: Math.round(Math.random() * 1000000),
gender: '',
houseId: '',
isAlive: true,
father: undefined,
mother: undefined,
siblings: [],
children: []
}
}
render() {
return (
<form onSubmit={(e) => {this.onSubmit(e)}}>
<label htmlFor="">First Name: </label>
<input type="text"
name="firstName"
value={this.state.firstName}
onChange={(e) => {this.setState({firstName: e.target.value})}}/>
<br/>
<label htmlFor="">Last Name: </label>
<input type="text"
name="lastName"
value={this.state.lastName}
onChange={(e) => {this.setState({lastName: e.target.value})}}/>
<br/>
<label htmlFor="">Gender: </label>
<input type="radio"
name="gender"
value={'male'}
id='gender-male'
onChange={(e) => {this.setState({gender: e.target.value})}}
selected={this.state.gender === 'male'}
/>
<label htmlFor="gender-male">Male</label>
<input type="radio"
name="gender"
value={'female'}
id='gender-female'
onChange={(e) => {this.setState({gender: e.target.value})}}
selected={this.state.gender === 'male'}
/>
<label htmlFor="gender-female">Female</label>
<br/>
<label htmlFor="">House: </label>
<select name="house" id=""
onChange={(e) => {this.setState({house: e.target.value})}}
defaultValue={this.state.house}
>
{
this.props.houses.map((house) => {
return (
<option key={house.name} value={house.id} selected={this.state.houseId === house.id}>{house.name}</option>
)
})
}
</select>
<br/>
<input type="submit"/>
</form>
)
}
onSubmit(e) {
e.preventDefault();
if(this.validate(this.state)){
this.props.createCharacter && this.props.createCharacter(this.state);
this.setState(this.initState());
}
}
validate(props) {
return true;
}
}
CreateCharacter.propTypes = {
createCharacter: PropTypes.func.isRequired
};Then, create the list item of the list.
./src/components/characters/item/spec.jsx
import React from 'react';
import { mount, shallow } from 'enzyme';
import chai, { expect } from 'chai';
import sinon from 'sinon';
import sinonChai from 'sinon-chai';
chai.use(sinonChai);
import { CharacterItem, CharacterItemWithEdit } from './index';
import { Father } from 'Fixtures/characters';
import { Houses } from 'Fixtures/houses';
describe('CharacterItem', () => {
const defaultProps = {
houses: Houses
};
const subject = (props) => {
const combinedProps = Object.assign({}, defaultProps, Father, props);
return mount(
<CharacterItem {...combinedProps}/>
)
};
describe('rendering', () => {
it('should have inputs', () => {
const component = subject({edit: () => {}, delete: () => {}});
expect(component.find('span').at(0).text()).to.eqls(Father.firstName);
expect(component.find('span').at(1).text()).to.eqls(Father.lastName);
expect(component.find('span').at(2).text()).to.eqls(Father.gender);
expect(component.find('span').at(3).text()).to.eqls('Stark');
expect(component.find('span').at(4).text()).to.eqls('Gone');
});
});
describe('edit', () => {
it('should call edit when button is clicked', () => {
const spy = sinon.spy();
const component = subject({edit: spy, delete: () => {}});
component.find('button').at(0).simulate('click');
expect(spy).to.have.callCount(1);
});
});
describe('delete', () => {
it('should call delete when button is clicked', () => {
const spy = sinon.spy();
const component = subject({edit: () => {}, delete: spy });
component.find('button').at(1).simulate('click');
expect(spy).to.have.callCount(1);
});
});
});
describe('CharacterItemWithEdit', () => {
const defaultProps = {
houses: Houses
};
const subject = (props) => {
const combinedProps = Object.assign({}, defaultProps, Father, props);
return mount(
<CharacterItemWithEdit {...combinedProps}/>
);
};
describe('CharacterItem component', () => {
it('should have component when it is not edit mode', () => {
const component = subject({edit: () => {}, delete: () => {}});
expect(component.find('CharacterItem').length).to.eqls(1);
});
});
describe('CreateCharacter component', () => {
it('should have component when it is edit mode', () => {
const component = subject();
component.find('CharacterItem button').at(0).simulate('click', {});
expect(component.find('CreateCharacter').length).to.eqls(1);
});
it('should show CharacterItem component when it is edit mode is done', () => {
const component = subject();
component.find('CharacterItem button').at(0).simulate('click', {});
expect(component.find('CreateCharacter').length).to.eqls(1);
component.find('CreateCharacter form').at(0).simulate('submit');
expect(component.find('CharacterItem').length).to.eqls(1);
});
})
});./src/components/characters/item/index.jsx
import React, { PropTypes } from 'react';
import { CreateCharacter } from '../create';
export function CharacterItem(props) {
function getHouse(houseId) {
return props.houses.filter((house) => {
return house.id === houseId;
})[0] || {};
}
return (<div>
<label htmlFor="">First name: </label>
<span>{props.firstName}</span>
<br/>
<label htmlFor="">Last name: </label>
<span>{props.lastName}</span>
<br/>
<label htmlFor="">Gender: </label>
<span>{props.gender}</span>
<br/>
<label htmlFor="">House: </label>
<span>{getHouse(props.houseId).name}</span>
<br/>
<label htmlFor="">Alive: </label>
<span>{props.isAlive ? 'Yes!' : 'Gone'}</span>
<br/>
<button onClick={props.edit}>edit</button>
<button onClick={props.delete}>delete</button>
</div>)
}
export class CharacterItemWithEdit extends React.Component {
constructor(options) {
super(options);
this.state = {
isEdit: false
}
}
render() {
return (
<div>
{ this.state.isEdit
? <CreateCharacter
{...this.props}
createCharacter={(e) => {console.log(e); this.setState({isEdit: false})}}
/>
: <CharacterItem edit={ () => {this.setState({isEdit: true})}}
delete={ () => { } }
{...this.props} /> }
</div>
)
}
}And finally the whole list component.
./src/components/characters/spec.jsx
import React from 'react';
import { mount } from 'enzyme';
import chai, { expect } from 'chai';
import sinonChai from 'sinon-chai';
chai.use(sinonChai);
import { CharacterList } from './index';
import { Characters } from 'Fixtures/characters';
import { Houses } from 'Fixtures/houses';
describe('CharacterList', () => {
const defaultProps = {
houses: Houses,
characters: Characters
};
const subject = (props) => {
const combinedProps = Object.assign({}, defaultProps, props);
return mount(
<CharacterList {...combinedProps}/>
);
};
describe('rendering', () => {
it('should display list', () => {
const component = subject();
expect(component.find('CharacterItemWithEdit').length).to.eqls(Characters.length);
});
it('should display create form', () => {
const component = subject();
expect(component.find('CreateCharacter').length).to.eqls(1);
});
});
});./src/components/characters/index.jsx
import React, { PropTypes } from 'react';
import { CharacterItemWithEdit } from './item';
import { CreateCharacter } from './create';
export class CharacterList extends React.Component {
render(){
return (
<div>
<ul>
{this.props.characters.map((character) => {
return <li key={`list-${character.id}`}>
<CharacterItemWithEdit
houses={this.props.houses}
{ ...character }
/>
</li>
})}
</ul>
<CreateCharacter
createCharacter={(e) => {console.log(e)}}
houses={this.props.houses}
/>
</div>
)
}
};Remember to update the Webpack configuration file, to enable the hot load.
./webpack.dev.config.js
entry: {
'got': [
'webpack/hot/only-dev-server',
'webpack-dev-server/client?http://localhost:3008',
'./src/app/got/index.jsx'
]
},
...
resolve: {
enforceExtension: false,
extensions: ['.js', '.jsx'],
alias: {
Fixtures: path.resolve(__dirname, 'src/fixtures'),
Reducers: path.resolve(__dirname, 'src/reducers'),
Constants: path.resolve(__dirname, 'src/constants'),
Services: path.resolve(__dirname, 'src/services'),
Components: path.resolve(__dirname, 'src/components')
}
},Create a store
Now, we are going to create the whole app. Start with creating a store.
NPM packages:
Install these packages. Since we separated the houses and characters, we also need to create a reducer for the houses.
./src/constants/houses/index.jsx
import mirrorCreator from 'mirror-creator';
export const actions = mirrorCreator([
'INIT_HOUSES'
]);./src/reducers/houses/spec.jsx
import { expect } from 'chai';
import reducers from './index';
import { actions } from 'Constants/houses';
import { Houses } from 'Fixtures/houses/index';
describe('houses reducers', () => {
describe('default', () => {
it('should return default state', () => {
const action = {
type: 'TEST'
};
expect(reducers(undefined, action)).to.eql([]);
});
});
describe('INIT_HOUSES', () => {
it('should return new state', () => {
const action = {
type: actions.INIT_HOUSES,
data: Houses
};
expect(reducers(undefined, action)).to.eqls(Houses);
});
});
});./src/reducers/houses/index.jsx
import { fromJS } from 'immutable';
import { createReducer } from 'redux-create-reducer';
import { actions } from 'Constants/houses';
export const initState = [];
function INIT_HOUSES(state, action) {
const houses = fromJS(state);
return houses.clear().push(...action.data).toJS();
}
export default createReducer(initState, {
[actions.INIT_HOUSES]: INIT_HOUSES
})then, connect the component to store with redux.
./src/components/characters/index.jsx
import React, { PropTypes } from 'react';
import { connect } from 'react-redux';
import { CharacterItemWithEdit } from './item';
import { CreateCharacter } from './create';...const mapStateToProps = state => ({
characters: state.characters,
houses: state.houses
});
export default connect(mapStateToProps)(CharacterList);
Create a store for the App.
./src/app/got/store.spec.jsx
import React from 'react';
import chai, { expect } from 'chai';
import sinonChai from 'sinon-chai';
chai.use(sinonChai);
import storeCreator from './store';
import { Characters } from 'Fixtures/characters';
import { Houses } from 'Fixtures/houses';
describe('init store', () => {
const initData = {
characters: Characters,
houses: Houses
};
it('should init with correct init data', () => {
expect(storeCreator(initData).getState()).to.eqls(initData);
});
});./src/app/got/store.jsx
import { combineReducers } from 'redux';
import thunk from "redux-thunk";
import { createStore, compose, applyMiddleware } from "redux";
import { initState as initCharacters, default as characters } from 'Reducers/characters';
import { initState as initHouses, default as houses } from 'Reducers/houses';
const mapPropsToInitState = (initData) => {
return {
characters: initData.characters || initCharacters,
houses: initData.houses || initHouses,
}
};
export default function storeCreator(props) {
const middlewares = [thunk];
const createStoreWrapper = compose(
applyMiddleware(...middlewares)
)(createStore);
const reducers = combineReducers({
characters, houses
});
const initData = mapPropsToInitState(props);
return createStoreWrapper(reducers, initData);
}Create actions for App
Create action mapping for the App is quite easy.
./app/got/actions.jsx
import { bindActionCreators } from "redux";
import * as characters from 'Actions/characters';
export default function bindActions(dispatch) {
return {
characterList: {
createCharacter: bindActionCreators(characters.createCharacter, dispatch),
removeCharacter: bindActionCreators(characters.removeCharacter, dispatch),
updateCharacter: bindActionCreators(characters.updateCharacter, dispatch)
}
}
};./webpack.dev.config.js
alias: {
Fixtures: path.resolve(__dirname, 'src/fixtures'),
Reducers: path.resolve(__dirname, 'src/reducers'),
Constants: path.resolve(__dirname, 'src/constants'),
Services: path.resolve(__dirname, 'src/services'),
Components: path.resolve(__dirname, 'src/components'),
Actions: path.resolve(__dirname, 'src/actions')
}Create a container
Importing components for the App. This is mostly for the route to switch to different container when it is necessary.
./src/app/container.spec.jsx
import React from "react";
import { expect } from "chai";
import { shallow } from "enzyme";
import sinon, { assert } from "sinon";
import { CharactersContainer as Container } from './container';
import { Characters } from 'Fixtures/characters';
import { Houses } from 'Fixtures/houses';
describe("<Characters>", ()=>{
let wrapper;
const props = {
characters: Characters,
houses: Houses,
actions: {
characterList: {
createCharacter: sinon.spy(),
removeCharacter: sinon.spy(),
updateCharacter: sinon.spy()
}
}
};
beforeEach(() => {
wrapper = shallow(<Container {...props} />);
});
describe('rendering', () => {
it('<CharacterList /> component', () => {
expect(wrapper.find('Connect(CharacterList)').length).to.eqls(1);
})
});
});./src/app/container.jsx
import React from 'react';
import { connect } from 'react-redux';
import CharacterList from 'Components/characters';
export const CharactersContainer = (props) => {
const { actions } = props;
return (
<div className="character-container">
<CharacterList actions={actions.characterList} />
</div>
)
};
export default connect()(CharactersContainer)Create an App
Since we are not building another server for debugging, we could create a mock service for local debug.
./src/services/mock/index.jsx
import { getBasicUrl, getRequestHeader } from 'Services/lib';
export function post(route, data) {
return {
done: (callback) => {
callback(data)
}
}
}
export function fetch(route, queryObject = {}) {
return {
done: (callback) => {
callback([])
}
}
}
export function update(route, data) {
return {
done: (callback) => {
callback(data)
}
}
}
export function destroy(route, data){
return {
done: (callback) => {
callback(data)
}
}
}update the action to import the mock service.
./src/actions/characters/index.jsx
import * as services from 'Services/mock';then create the App index file.
./src/app/got/index.jsx
import React from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import storeCreator from './store';
import { default as CharactersContainer } from './container';
import bindActions from "./actions";
//data for debug
import { Characters } from 'Fixtures/characters';
import { Houses } from 'Fixtures/houses';
const initData = {
characters: Characters,
houses: Houses
};
const store = storeCreator(initData);
const actions = bindActions(store.dispatch);
const id = 'app-container';
ReactDOM.render(
<Provider store={store} >
<CharactersContainer actions={actions}/>
</Provider>,
document.getElementById(id)
);Here we go
Now we can start the dev server to see what would happen with command line:
npm startThen visit localhost:3000/got to check the page.
