Redux quick start

Gabriel Cheung
Aug 22, 2017 · 18 min read

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

  1. Switching from OOP to FP is difficult to some degree.
  2. Implementing Redux requires tons of NPM package that you need to know.
  3. 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.

3–1 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.

3–2 structure of our app

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 init

Then we could build the folders like 3–3

3–3 folder structure

Commit on Github

Start with Webpack

NPM packages that you may need to know

  1. Webpack — module bundler
  2. Babel — Javascript compiler
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 install

Also, 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.

4–1 compiling test

Commit on Github

Config for Dev server

And now we could set the config for the dev server.

NPM packages:

  1. webpack-dev-server
  2. html-webpack-plugin

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 start

Visiting 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:dev

Then 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:

  1. chai
  2. enzyme
  3. sinon
  4. sinon-chai
  5. mocha
  6. React
  7. jsdom
  8. react-dom

Install packages with npm:

npm install —-save-dev react react-test-renderer react-dom enzyme chai sinon-chai sinon jsdom@9.9.1 mocha

Why 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 test

And there will be a report of the test

test
✓ test input
1 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.

6–1 test frameworks working together

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:

  1. Immutable
  2. redux-create-reducer
  3. mirror-creator(optional)

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:

  1. jQuery
  2. query-string

Install with command line

npm install --save-dev jquery query-string

Create 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')
}
},

Commit on Github

Create a store

Now, we are going to create the whole app. Start with creating a store.

NPM packages:

  1. redux-thunk
  2. redux
  3. react-redux
  4. redux-immutable

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);
}

Commit on Github

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')
}

Commit on Github

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)

Commit on Github

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 start

Then visit localhost:3000/got to check the page.

)
Welcome to a place where words matter. On Medium, smart voices and original ideas take center stage - with no ads in sight. Watch
Follow all the topics you care about, and we’ll deliver the best stories for you to your homepage and inbox. Explore
Get unlimited access to the best stories on Medium — and support writers while you’re at it. Just $5/month. Upgrade