Add react/ frontend tutorials

This commit is contained in:
Timothy Warren 2016-08-03 20:38:01 -04:00
parent a1422818ee
commit a2c108af80
35 changed files with 1336 additions and 1 deletions

6
.gitignore vendored
View File

@ -677,4 +677,8 @@ fabric.properties
*.ear
# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
hs_err_pid*
hs_err_pid*
# Ignore all the stupid idea garbage
**/.idea/**/*.xml
**/.idea/**/*.iml

View File

@ -0,0 +1,50 @@
{
"name": "voting-client",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"start": "webpack-dev-server --history-api-fallback",
"test": "mocha --compilers js:babel-core/register --require ./test/test_helper.js \"test/**/*@(.js|.jsx)\"",
"test:watch": "npm run test -- --watch"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"autoprefixer": "^6.3.6",
"autoprefixer-loader": "^3.2.0",
"babel-core": "^6.9.1",
"babel-loader": "^6.2.4",
"babel-preset-es2015": "^6.9.0",
"babel-preset-react": "^6.5.0",
"chai": "^3.5.0",
"chai-immutable": "^1.6.0",
"css-loader": "^0.23.1",
"jsdom": "^9.2.1",
"mocha": "^2.5.3",
"postcss-loader": "^0.9.1",
"react-hot-loader": "^1.3.0",
"style-loader": "^0.13.1",
"webpack": "^1.13.1",
"webpack-dev-server": "^1.14.1"
},
"babel": {
"presets": [
"es2015",
"react"
]
},
"dependencies": {
"classnames": "^2.2.5",
"immutable": "^3.8.1",
"react": "^15.1.0",
"react-addons-pure-render-mixin": "^15.1.0",
"react-addons-test-utils": "^15.1.0",
"react-dom": "^15.1.0",
"react-redux": "^4.4.5",
"react-router": "^2.4.1",
"redux": "^3.5.2",
"socket.io-client": "^1.4.6"
}
}

View File

@ -0,0 +1,21 @@
export function setState(state) {
return {
type: 'SET_STATE',
state
};
}
export function vote(entry) {
return {
meta: {remote: true},
type: 'VOTE',
entry
};
}
export function next() {
return {
meta: {remote: true},
type: 'NEXT'
};
}

View File

@ -0,0 +1,7 @@
import React from 'react';
export default React.createClass({
render: function() {
return this.props.children;
}
});

View File

@ -0,0 +1,62 @@
import React from 'react';
import PureRenderMixin from 'react-addons-pure-render-mixin';
import {connect} from 'react-redux';
import Winner from './Winner';
import * as actionCreators from '../action_creators';
export const VOTE_WIDTH_PERCENT = 8;
export const Results = React.createClass({
mixins: [PureRenderMixin],
getPair: function () {
return this.props.pair || [];
},
getVotes: function(entry) {
if (this.props.tally && this.props.tally.has(entry)) {
return this.props.tally.get(entry);
}
return 0;
},
getVotesBlockWidth: function(entry) {
console.log(entry);
console.log(this.getVotes(entry));
return (this.getVotes(entry) * VOTE_WIDTH_PERCENT) + '%';
},
render: function () {
return this.props.winner ?
<Winner ref="winner" winner={this.props.winner} /> :
<div className="results">
<div className="tally">
{this.getPair().map(entry =>
<div key={entry} className="entry">
<h1>{entry}</h1>
<div className="voteVisualization">
<div className="votesBlock"
style={{width: this.getVotesBlockWidth(entry)}}>
</div>
</div>
<div className="voteCount">
{this.getVotes(entry)}
</div>
</div>
)}
</div>
<div className="management">
<button ref="next"
className="next"
onClick={this.props.next}>Next</button>
</div>
</div>;
}
});
function mapStateToProps(state) {
return {
pair: state.getIn(['vote', 'pair']),
tally: state.getIn(['vote', 'tally']),
winner: state.get('winner')
}
}
export const ResultsContainer = connect(mapStateToProps, actionCreators)(Results);

View File

@ -0,0 +1,30 @@
import React from 'react';
import PureRenderMixin from 'react-addons-pure-render-mixin';
import classNames from 'classnames';
export default React.createClass({
mixins: [PureRenderMixin],
getPair: function() {
return this.props.pair || [];
},
isDisabled: function() {
return !! this.props.hasVoted;
},
hasVotedFor: function(entry) {
return this.props.hasVoted === entry;
},
render: function() {
return <div className="voting">
{this.getPair().map(entry =>
<button key={entry}
className={classNames({voted: this.hasVotedFor(entry)})}
disabled={this.isDisabled()}
onClick={() => this.props.vote(entry)}>
<h1>{entry}</h1>
{this.hasVotedFor(entry) ?
<div className="label">Voted</div> : null}
</button>
)}
</div>;
}
});

View File

@ -0,0 +1,28 @@
import React from 'react';
import PureRenderMixin from 'react-addons-pure-render-mixin';
import {connect} from 'react-redux';
import Winner from './Winner';
import Vote from './Vote';
import * as actionCreators from '../action_creators';
export const Voting = React.createClass({
mixins: [PureRenderMixin],
render: function() {
return <div>
{this.props.winner
? <Winner ref="winner" winner={this.props.winner} />
: <Vote {...this.props} />}
</div>;
}
});
function mapStateToProps(state) {
return {
pair: state.getIn(['vote', 'pair']),
hasVoted: state.get('hasVoted'),
winner: state.get('winner')
};
}
export const VotingContainer = connect(mapStateToProps, actionCreators)(Voting);

View File

@ -0,0 +1,11 @@
import React from 'react';
import PureRenderMixin from 'react-addons-pure-render-mixin';
export default React.createClass({
mixins: [PureRenderMixin],
render: function() {
return <div className="winner">
Winner is {this.props.winner}!
</div>;
}
});

View File

@ -0,0 +1,34 @@
import React from 'react';
import ReactDOM from 'react-dom';
import {Router, Route, browserHistory} from 'react-router';
import {createStore, applyMiddleware} from 'redux';
import {Provider} from 'react-redux';
import io from 'socket.io-client';
import reducer from './reducer';
import {setState} from './action_creators';
import remoteActionMiddleware from './remote_action_middleware';
import App from './components/App';
import {VotingContainer} from './components/Voting';
import {ResultsContainer} from './components/Results';
require('./style.css');
const socket = io(`${location.protocol}//${location.hostname}:8090`);
socket.on('state', state => store.dispatch(setState(state)));
const createStoreWidthMiddleware = applyMiddleware(
remoteActionMiddleware(socket)
)(createStore);
const store = createStoreWidthMiddleware(reducer);
const routes = <Route component={App}>
<Route path="/results" component={ResultsContainer} />
<Route path="/" component={VotingContainer} />
</Route>;
ReactDOM.render(
<Provider store={store}>
<Router history={browserHistory}>{routes}</Router>
</Provider>,
document.getElementById('app')
);

View File

@ -0,0 +1,42 @@
import {Map} from 'immutable';
const SET_STATE = 'SET_STATE';
const VOTE = 'VOTE';
function setState(state, newState) {
return state.merge(newState);
}
function vote(state, entry) {
const currentPair = state.getIn(['vote', 'pair']);
if (currentPair && currentPair.includes(entry)) {
return state.set('hasVoted', entry);
}
return state;
}
function resetVote(state) {
const hasVoted = state.get('hasVoted');
const currentPair = state.getIn(['vote', 'pair']);
if (hasVoted && ! currentPair.includes(hasVoted)) {
return state.remove('hasVoted');
}
return state;
}
export default function(state = Map(), action) {
switch(action.type) {
case SET_STATE:
return resetVote(setState(state, action.state));
case VOTE:
return vote(state, action.entry);
default:
return state;
}
}

View File

@ -0,0 +1,6 @@
export default socket => store => next => action => {
if (action.meta && action.meta.remote) {
socket.emit('action', action);
}
return next(action);
};

View File

@ -0,0 +1,123 @@
body {
font-family: 'Open Sans', sans-serif;
background-color: #673AB7;
color: white;
}
/* Voting Screen */
.voting {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
user-select: none;
}
.voting button {
flex: 1 0 0;
background-color: #673AB7;
border-width: 0;
}
.voting button:first-child {
border-bottom: 1px solid white;
}
.voting button:active {
background-color: white;
color: #311B92;
}
.voting button.voted {
background-color: #311B92;
}
.voting button:not(.voted) .label {
visibility: hidden;
}
.voting button .label {
opacity: 0.87;
}
.voting button.votedAgainst * {
opacity: 0.3;
}
@media only screen and (min-device-width: 500px) {
.voting {
flex-direction: row;
}
.voting button:first-child {
border-bottom-width: 0;
border-right: 1px solid white;
}
}
/* Results Screen */
.results {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
}
.results .tally {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
}
.results .tally .entry {
display: flex;
justify-content: space-around;
align-items: center;
}
.results .tally h1 {
width: 25%;
}
.results .tally .voteVisualization {
height: 50px;
width: 50%;
display: flex;
justify-content: flex-start;
background-color: #7E57C2;
}
.results .tally .votesBlock {
background-color: white;
transition: width 0.5s;
}
.results .tally .voteCount {
font-size: 2rem;
}
.results .management {
display: flex;
height: 2em;
border-top: 1px solid #aaa;
}
.results .management button {
border: 0;
background-color: black;
color: #aaa;
}
.results .management .next {
flex: 1;
}
/* Winner View */
.winner {
font-size: 4rem;
text-align: center;
}

View File

@ -0,0 +1,54 @@
import React from 'react';
import ReactDOM from 'react-dom';
import {
renderIntoDocument,
scryRenderedDOMComponentsWithClass,
Simulate
} from 'react-addons-test-utils';
import {List, Map} from 'immutable';
import {Results} from '../../src/components/Results';
import {expect} from 'chai';
describe('Results', () => {
it('renders entries with vote counts or zero', () => {
const pair = List.of('Trainspotting', '28 Days Later');
const tally = Map({'Trainspotting': 5});
const component = renderIntoDocument(
<Results pair={pair} tally={tally} />
);
const entries = scryRenderedDOMComponentsWithClass(component, 'entry');
const [train, days] = entries.map(e => e.textContent);
expect(entries.length).to.equal(2);
expect(train).to.contain('Trainspotting');
expect(train).to.contain('5');
expect(days).to.contain('28 Days Later');
expect(days).to.contain('0');
});
it('invokes the next callback when next button is clicked', () => {
let nextInvoked = false;
const next = () => nextInvoked = true;
const pair = List.of('Trainspotting', '28 Days Later');
const component = renderIntoDocument(
<Results pair={pair} tally={Map()} next={next} />
);
Simulate.click(ReactDOM.findDOMNode(component.refs.next));
expect(nextInvoked).to.equal(true);
});
it('renders the winner when there is one', () => {
const conponent = renderIntoDocument(
<Results winner="Trainspotting"
pair={["Trainspotting", "28 Days Later"]}
tally={Map()} />
);
const winner = ReactDOM.findDOMNode(conponent.refs.winner);
expect(winner).to.be.ok;
expect(winner.textContent).to.contain('Trainspotting');
});
});

View File

@ -0,0 +1,112 @@
import React from 'react';
import ReactDOM from 'react-dom';
import {
renderIntoDocument,
scryRenderedDOMComponentsWithTag,
Simulate
} from 'react-addons-test-utils';
import {List} from 'immutable';
import {Voting} from '../../src/components/Voting';
import {expect} from 'chai';
describe('Voting', () => {
it('renders a pair of buttons', () => {
const component = renderIntoDocument(
<Voting pair={["Trainspotting", "28 Days Later"]} />
);
const buttons = scryRenderedDOMComponentsWithTag(component, 'button');
expect(buttons.length).to.equal(2);
expect(buttons[0].textContent).to.equal('Trainspotting');
expect(buttons[1].textContent).to.equal('28 Days Later');
});
it('invokes callback when a button is clicked', () => {
let votedWith;
const vote = (entry) => votedWith = entry;
const component = renderIntoDocument(
<Voting pair={["Trainspotting", "28 Days Later"]} vote={vote} />
);
const buttons = scryRenderedDOMComponentsWithTag(component, 'button');
Simulate.click(buttons[0]);
expect(votedWith).to.equal('Trainspotting');
});
it('disables buttons when user has voted', () => {
const component = renderIntoDocument(
<Voting pair={["Trainspotting", "28 Days Later"]}
hasVoted="Trainspotting" />
);
const buttons = scryRenderedDOMComponentsWithTag(component, 'button');
expect(buttons.length).to.equal(2);
expect(buttons[0].hasAttribute('disabled')).to.equal(true);
expect(buttons[1].hasAttribute('disabled')).to.equal(true);
});
it('adds label to the voted entry', () => {
const component = renderIntoDocument(
<Voting pair={["Trainspotting", "28 Days Later"]}
hasVoted="Trainspotting" />
);
const buttons = scryRenderedDOMComponentsWithTag(component, 'button');
expect(buttons[0].textContent).to.contain('Voted');
});
it('renders just the winner when there is one', () => {
const component = renderIntoDocument(
<Voting winner="Trainspotting" />
);
const buttons = scryRenderedDOMComponentsWithTag(component, 'button');
expect(buttons.length).to.equal(0);
const winner = ReactDOM.findDOMNode(component.refs.winner);
expect(winner).to.be.ok;
expect(winner.textContent).to.contain('Trainspotting');
});
it('renders as a pure component', () => {
const pair = ['Trainspotting', '28 Days Later'];
const container = document.createElement('div');
let component = ReactDOM.render(
<Voting pair={pair} />,
container
);
let firstButton = scryRenderedDOMComponentsWithTag(component, 'button')[0];
expect(firstButton.textContent).to.equal('Trainspotting');
pair[0] = 'Sunshine';
component = ReactDOM.render(
<Voting pair={pair} />,
container
);
firstButton = scryRenderedDOMComponentsWithTag(component, 'button')[0];
expect(firstButton.textContent).to.equal('Trainspotting');
});
it('does update DOM when prop changes', () => {
const pair = List.of('Trainspotting', '28 Days Later');
const container = document.createElement('div');
let component = ReactDOM.render(
<Voting pair={pair} />,
container
);
let firstButton = scryRenderedDOMComponentsWithTag(component, 'button')[0];
expect(firstButton.textContent).to.equal('Trainspotting');
const newPair = pair.set(0, 'Sunshine');
component = ReactDOM.render(
<Voting pair={newPair} />,
container
);
firstButton = scryRenderedDOMComponentsWithTag(component, 'button')[0];
expect(firstButton.textContent).to.equal('Sunshine');
});
});

View File

@ -0,0 +1,133 @@
import {List, Map, fromJS} from 'immutable';
import {expect} from 'chai';
import reducer from '../src/reducer';
describe('reducer', () => {
it('handles SET_STATE', () => {
const initialState = Map();
const action = {
type: 'SET_STATE',
state: Map({
vote: Map({
pair: List.of('Trainspotting', '28 Days Later'),
tally: Map({
Trainspotting: 1
})
})
})
};
const nextState = reducer(initialState, action);
expect(nextState).to.equal(fromJS({
vote: {
pair: ['Trainspotting', '28 Days Later'],
tally: {Trainspotting: 1}
}
}));
});
it('handles SET_STATE with plain JS payload', () => {
const initialState = Map();
const action = {
type: 'SET_STATE',
state: {
vote: {
pair: ['Trainspotting', '28 Days Later'],
tally: {Trainspotting: 1}
}
}
};
const nextState = reducer(initialState, action);
expect(nextState).to.equal(fromJS({
vote: {
pair: ['Trainspotting', '28 Days Later'],
tally: {Trainspotting: 1}
}
}));
});
it('handles SET_STATE without initial state', () => {
const action = {
type: 'SET_STATE',
state: {
vote: {
pair: ['Trainspotting', '28 Days Later'],
tally: {Trainspotting: 1}
}
}
};
const nextState = reducer(undefined, action);
expect(nextState).to.equal(fromJS({
vote: {
pair: ['Trainspotting', '28 Days Later'],
tally: {Trainspotting: 1}
}
}));
});
it('handles VOTE by setting hasVoted', () => {
const state = fromJS({
vote: {
pair: ['Trainspotting', '28 Days Later'],
tally: {Trainspotting: 1}
}
});
const action = {type: 'VOTE', entry: 'Trainspotting'};
const nextState = reducer(state, action);
expect(nextState).to.equal(fromJS({
vote: {
pair: ['Trainspotting', '28 Days Later'],
tally: {Trainspotting: 1}
},
hasVoted: 'Trainspotting'
}));
});
it('does not set hasVoted for VOTE on invalid entry', () => {
const state = fromJS({
vote: {
pair: ['Trainspotting', '28 Days Later'],
tally: {Trainspotting: 1}
}
});
const action = {type: 'VOTE', entry: 'Sunshine'};
const nextState = reducer(state, action);
expect(nextState).to.equal(fromJS({
vote: {
pair: ['Trainspotting', '28 Days Later'],
tally: {Trainspotting: 1}
}
}));
});
it('removes hasVoted on SET_STATE if pair changes', () => {
const initialState = fromJS({
vote: {
pair: ['Trainspotting', '28 Days Later'],
tally: {Trainspotting: 1}
},
hasVoted: 'Trainspotting'
});
const action = {
type: 'SET_STATE',
state: {
vote: {
pair: ['Sunshine', 'Slumdog Millionaire']
}
}
};
const nextState = reducer(initialState, action);
expect(nextState).to.equal(fromJS({
vote: {
pair: ['Sunshine', 'Slumdog Millionaire']
}
}));
});
});

View File

@ -0,0 +1,21 @@
/**
* Fakes a browser environment for testing react
*/
import jsdom from 'jsdom';
import chai from 'chai';
import chaiImmutable from 'chai-immutable';
const doc = jsdom.jsdom('<!DOCTYPE html><html><body></body></html>');
const win = doc.defaultView;
global.document = doc;
global.window = win;
// Hoist window properties to global
Object.keys(window).forEach(key => {
if ( ! (key in global)) {
global[key] = window[key];
}
});
chai.use(chaiImmutable);

View File

@ -0,0 +1,39 @@
const autoprefixer = require('autoprefixer');
const webpack = require('webpack');
module.exports = {
entry: [
'webpack-dev-server/client?http://localhost:8080',
'webpack/hot/only-dev-server',
'./src/index.jsx'
],
module: {
loaders: [{
text: /\.jsx?$/,
exclude: /node_modules/,
loader: 'react-hot!babel'
}, {
test: /\.css$/,
loader: 'style-loader!css-loader!postcss-loader'
}]
},
resolve: {
extensions: ['', '.js', '.jsx']
},
output: {
path: `${__dirname}/dist`,
publicPath: '/',
filename: 'bundle.js'
},
devServer: {
contentBase: './dist',
hot: true
},
plugins: [
new webpack.HotModuleReplacementPlugin()
],
postcss: () => {
return [autoprefixer];
},
devtool: '#source-map'
};

View File

@ -0,0 +1,13 @@
[
"Shallow Grave",
"Trainspotting",
"A Life Less Ordinary",
"The Beach",
"28 Days Later",
"Millions",
"Sunshine",
"Slumdog Millionaire",
"127 Hours",
"Trance",
"Steve Jobs"
]

View File

@ -0,0 +1,23 @@
// Overall summary of architecture:
// 1. A client sends an action to the server
// 2. The server hands the action to the Redux store
// 3. The store calls the reducer and the reducer executes the logic related to
// the action
// 4. The store updates its state based on the return value of the reducer
// 5. The store executes its listener function subscribed by the server
// 6. The server emits a 'state' event
// 7. All connected clients -- including the on that initiated teh original
// action -- receive the new state
import makeStore from './src/store';
import startServer from './src/server';
export const store = makeStore();
startServer(store);
// Preload the state
store.dispatch({
type: 'SET_ENTRIES',
entries: require('./entries.json')
});
store.dispatch({type: 'NEXT'});

View File

@ -0,0 +1,32 @@
{
"name": "voting-server",
"version": "1.0.0",
"description": "",
"main": "index.js",
"babel": {
"presets": [
"es2015"
]
},
"scripts": {
"start": "babel-node index.js",
"test": "mocha --compilers js:babel-core/register --require ./test/test_helper.js --recursive",
"test:watch": "npm run test -- --watch"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"babel-cli": "^6.10.1",
"babel-core": "^6.9.1",
"babel-preset-es2015": "^6.9.0",
"chai": "^3.5.0",
"chai-immutable": "^1.6.0",
"mocha": "^2.5.3"
},
"dependencies": {
"immutable": "^3.8.1",
"redux": "^3.5.2",
"socket.io": "^1.4.6"
}
}

View File

@ -0,0 +1,46 @@
import {List, Map} from 'immutable';
export const INITIAL_STATE = Map();
export function setEntries(state, entries) {
return state.set('entries', List(entries));
}
function getWinners(vote) {
if ( ! vote) return [];
const [a, b] = vote.get('pair');
const aVotes = vote.getIn(['tally', a], 0);
const bVotes = vote.getIn(['tally', b], 0);
if (aVotes > bVotes) return [a];
else if (aVotes < bVotes) return [b];
else {
return [a, b]
}
}
export function next(state) {
const entries = state.get('entries')
.concat(getWinners(state.get('vote')));
if (entries.size === 1) {
return state.remove('vote')
.remove('entries')
.set('winner', entries.first());
} else {
return state.merge({
vote: Map({
pair: entries.take(2)
}),
entries: entries.skip(2)
});
}
}
export function vote(voteState, entry) {
return voteState.updateIn(
['tally', entry],
0,
tally => tally + 1
);
}

View File

@ -0,0 +1,19 @@
import {setEntries, next, vote, INITIAL_STATE} from './core';
export default function reducer(state = INITIAL_STATE, action) {
// Figure out which function to call and call it
switch(action.type) {
case 'SET_ENTRIES':
return setEntries(state, action.entries);
case 'NEXT':
return next(state);
case 'VOTE':
return state.update('vote',
voteState => vote(voteState, action.entry));
default:
return state;
}
}

View File

@ -0,0 +1,20 @@
import Server from 'socket.io';
export default function startServer(store) {
const io = new Server().attach(8090);
store.subscribe(
// Send 'state' events when updating state via Redux
() => io.emit('state', store.getState().toJS())
);
io.on('connection', socket => {
// Send state on connection
socket.emit('state', store.getState().toJS());
// Subscribe to action events sent by clients
// NOTE: this is dangerously insecure -- any client can trigger
// any action
socket.on('action', store.dispatch.bind(store));
});
}

View File

@ -0,0 +1,6 @@
import {createStore} from 'redux';
import reducer from './reducer';
export default function makeStore() {
return createStore(reducer);
}

View File

@ -0,0 +1,133 @@
import {List, Map} from 'immutable';
import {expect} from 'chai';
import {setEntries, next, vote} from '../src/core';
describe('application logic', () => {
describe('setEntries', () => {
it('adds the entries to the state', () => {
const state = Map();
const entries = List.of('Trainspotting', '28 Days Later');
const nextState = setEntries(state, entries);
expect(nextState).to.equal(Map({
entries: List.of('Trainspotting', '28 Days Later')
}));
});
it('converts to immutable', () => {
const state = Map();
const entries = ['Trainspotting', '28 Days Later'];
const nextState = setEntries(state, entries);
expect(nextState).to.equal(Map({
entries: List.of('Trainspotting', '28 Days Later')
}));
});
});
describe('next', () => {
it('takes the next two entries under vote', () => {
const state = Map({
entries: List.of('Trainspotting', '28 Days Later', 'Sunshine')
});
const nextState = next(state);
expect(nextState).to.equal(Map({
vote: Map({
pair: List.of('Trainspotting', '28 Days Later')
}),
entries: List.of('Sunshine')
}));
});
it('puts winner of current vote back to entries', () => {
const state = Map({
vote: Map({
pair: List.of('Trainspotting', '28 Days Later'),
tally: Map({
'Trainspotting': 4,
'28 Days Later': 2
})
}),
entries: List.of('Sunshine', 'Millions', '127 Hours')
});
const nextState = next(state);
expect(nextState).to.equal(Map({
vote: Map({
pair: List.of('Sunshine', 'Millions')
}),
entries: List.of('127 Hours', 'Trainspotting')
}));
});
it('puts both from tied vote back to entries', () => {
const state = Map({
vote: Map({
pair: List.of('Trainspotting', '28 Days Later'),
tally: Map({
'Trainspotting': 3,
'28 Days Later': 3
})
}),
entries: List.of('Sunshine', 'Millions', '127 Hours')
});
const nextState = next(state);
expect(nextState).to.equal(Map({
vote: Map({
pair: List.of('Sunshine', 'Millions')
}),
entries: List.of('127 Hours', 'Trainspotting', '28 Days Later')
}));
});
it('marks winner when just one entry left', () => {
const state = Map({
vote: Map({
pair: List.of('Trainspotting', '28 Days Later'),
tally: Map({
'Trainspotting': 4,
'28 Days Later': 2
})
}),
entries: List()
});
const nextState = next(state);
expect(nextState).to.equal(Map({
winner: 'Trainspotting'
}));
});
});
describe('vote', () => {
it('creates a tally for the voted entry', () => {
const state = Map({
pair: List.of('Trainspotting', '28 Days Later')
});
const nextState = vote(state, 'Trainspotting');
expect(nextState).to.equal(Map({
pair: List.of('Trainspotting', '28 Days Later'),
tally: Map({
Trainspotting: 1
})
}));
});
it('adds to existing tally for the voted entry', () => {
const state = Map({
pair: List.of('Trainspotting', '28 Days Later'),
tally: Map({
'Trainspotting': 3,
'28 Days Later': 2
})
});
const nextState = vote(state, 'Trainspotting');
expect(nextState).to.equal(Map({
pair: List.of('Trainspotting', '28 Days Later'),
tally: Map({
'Trainspotting': 4,
'28 Days Later': 2
})
}));
});
});
});

View File

@ -0,0 +1,66 @@
import {expect} from 'chai';
import {List, Map} from 'immutable';
describe('immutability', () => {
describe('a number', () => {
function increment(currentState) {
return currentState + 1;
}
it('is immutable', () => {
let state = 42;
let nextState = increment(state);
expect(nextState).to.equal(43);
expect(state).to.equal(42);
});
});
describe('a list', () => {
function addMovie(currentState, movie) {
return currentState.push(movie);
}
it('is immutable', () => {
let state = List.of('Trainspotting', '28 Days Later');
let nextState = addMovie(state, 'Sunshine');
expect(nextState).to.equal(List.of(
'Trainspotting',
'28 Days Later',
'Sunshine'
));
expect(state).to.equal(List.of(
'Trainspotting',
'28 Days Later'
));
});
});
describe('a tree', () => {
function addMovie(currentState, movie) {
return currentState.update('movies', movies => movies.push(movie));
}
it('is immutable', () => {
let state = Map({
movies: List.of('Trainspotting', '28 Days Later')
});
let nextState = addMovie(state, 'Sunshine');
expect(nextState).to.equal(Map({
movies: List.of(
'Trainspotting',
'28 Days Later',
'Sunshine'
)
}));
expect(state).to.equal(Map({
movies: List.of(
'Trainspotting',
'28 Days Later'
)
}));
});
});
});

View File

@ -0,0 +1,77 @@
import {Map, fromJS} from 'immutable';
import {expect} from 'chai';
import reducer from '../src/reducer';
describe('reducer', () => {
it('handles SET_ENTRIES', () => {
const initialState = Map();
const action = {type: 'SET_ENTRIES', entries: ['Trainspotting']};
const nextState = reducer(initialState, action);
expect(nextState).to.equal(fromJS({
entries: ['Trainspotting']
}));
});
it('handles NEXT', () => {
const initialState = fromJS({
entries: ['Trainspotting', '28 Days Later']
});
const action = {type: 'NEXT'};
const nextState = reducer(initialState, action);
expect(nextState).to.equal(fromJS({
vote: {
pair: ['Trainspotting', '28 Days Later']
},
entries: []
}));
});
it('handles VOTE', () => {
const initialState = fromJS({
vote: {
pair: ['Trainspotting', '28 Days Later']
},
entries: []
});
const action = {type: 'VOTE', entry: 'Trainspotting'};
const nextState = reducer(initialState, action);
expect(nextState).to.equal(fromJS({
vote: {
pair: ['Trainspotting', '28 Days Later'],
tally: {
Trainspotting: 1
}
},
entries: []
}));
});
it('has an initial state', () => {
const action = {type: 'SET_ENTRIES', entries: ['Trainspotting']};
const nextState = reducer(undefined, action);
expect(nextState).to.equal(fromJS({
entries: ['Trainspotting']
}));
});
it('can be used with reduce', () => {
const actions = [
{type: 'SET_ENTRIES', entries: ['Trainspotting', '28 Days Later']},
{type: 'NEXT'},
{type: 'VOTE', entry: 'Trainspotting'},
{type: 'VOTE', entry: '28 Days Later'},
{type: 'VOTE', entry: 'Trainspotting'},
{type: 'NEXT'}
];
const finalState = actions.reduce(reducer, Map());
expect(finalState).to.equal(fromJS({
winner: 'Trainspotting'
}));
});
});

View File

@ -0,0 +1,21 @@
import {Map, fromJS} from 'immutable';
import {expect} from 'chai';
import makeStore from '../src/store';
describe('store', () => {
it('is a Redux store configured with the correct reducer', () => {
const store = makeStore();
expect(store.getState()).to.equal(Map());
store.dispatch({
type: 'SET_ENTRIES',
entries: ['Trainspotting', '28 Days Later']
});
expect(store.getState()).to.equal(fromJS({
entries: ['Trainspotting', '28 Days Later']
}));
});
});

View File

@ -0,0 +1,4 @@
import chai from 'chai';
import chaiImmutable from 'chai-immutable';
chai.use(chaiImmutable);

View File

@ -0,0 +1,3 @@
{
"presets": ["react", "es2015"]
}

View File

@ -0,0 +1,28 @@
{
"name": "respotify",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"start": "webpack-dev-server --hot --inline",
"build": "webpack",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"babel-core": "^6.10.4",
"babel-loader": "^6.2.4",
"babel-preset-es2015": "^6.9.0",
"babel-preset-react": "^6.11.1",
"file-loader": "^0.9.0",
"react-hot-loader": "^1.3.0",
"webpack": "^1.13.1",
"webpack-dev-server": "^1.14.1"
},
"dependencies": {
"react": "^15.2.0",
"react-dom": "^15.2.0"
}
}

View File

@ -0,0 +1,11 @@
import React from "react";
export default React.createClass({
render: function() {
return (
<div className="greeting">
Hello, {this.props.name}!
</div>
)
}
})

View File

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html>
<head>
<title>Respotify</title>
<meta charset="utf-8" />
</head>
<body>
<h1>Respotify</h1>
<div id="container"></div>
<script src="/bundle.js"></script>
</body>
</html>

View File

@ -0,0 +1,8 @@
import React from "react";
import ReactDOM from "react-dom";
import Greeting from "./greeting";
ReactDOM.render(
<Greeting name="World" />,
document.getElementById('container')
);

View File

@ -0,0 +1,36 @@
const webpack = require('webpack');
const path = require('path');
const PATHS = {
app: './src/index.js',
html: './src/index.html',
dist: path.join(__dirname, 'dist')
};
module.exports = {
entry: {
javascript: PATHS.app,
html: PATHS.html
},
output: {
path: PATHS.dist,
publicPath: '/',
filename: 'bundle.js'
},
devServer: {
contentBase: PATHS.dist
},
module: {
loaders: [
{
test: /\.html$/,
loader: "file?name=[name].[ext]"
},
{
test: /\.js$/,
exclude: /node_modules/,
loaders: ["react-hot", "babel-loader"]
}
]
}
};