Skip to content

Commit

Permalink
Finished: End of tutorial
Browse files Browse the repository at this point in the history
  • Loading branch information
remypar5 committed Jul 8, 2016
1 parent c6d9ab0 commit aae4ed2
Show file tree
Hide file tree
Showing 16 changed files with 640 additions and 0 deletions.
8 changes: 8 additions & 0 deletions dist/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<!doctype html>
<html>
<body>
<a href="#">Home</a> - <a href="#/results">Results</a>
<div id="app"></div>
<script src="bundle.js"></script>
</body>
</html>
42 changes: 42 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
{
"name": "voting-client",
"version": "1.0.0",
"description": "A client for handling a list of entries to determine the group's favorite. This project is fully based on http://teropa.info/blog/2015/09/10/full-stack-redux-tutorial.html",
"main": "index.js",
"babel": {
"presets": [
"es2015",
"react"
]
},
"scripts": {
"test": "mocha --compilers js:babel-core/register --require ./test/test_helper.js \"test/**/*@(.js|.jsx)\"",
"test:watch": "npm run test -- --watch"
},
"author": "Remy Parzinski",
"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",
"chai": "^3.5.0",
"chai-immutable": "^1.6.0",
"jsdom": "^9.4.1",
"mocha": "^2.5.3",
"react-hot-loader": "^1.3.0",
"webpack": "^1.13.1",
"webpack-dev-server": "^1.14.1"
},
"dependencies": {
"immutable": "^3.8.1",
"react": "^15.2.0",
"react-addons-pure-render-mixin": "^15.2.0",
"react-addons-test-utils": "^15.2.0",
"react-dom": "^15.2.0",
"react-redux": "^4.4.5",
"react-router": "^2.0.0",
"redux": "^3.5.2",
"socket.io-client": "^1.4.8"
}
}
25 changes: 25 additions & 0 deletions src/action_creators.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
export function setState(state) {
return {
type: 'SET_STATE',
state
};
}

export function vote(entry) {
return {
type: 'VOTE',
entry,
meta: {
remote: true
}
};
}

export function next() {
return {
type: 'NEXT',
meta: {
remote: true
}
}
}
8 changes: 8 additions & 0 deletions src/components/App.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import React from 'react';

export default React.createClass({

render: function() {
return this.props.children;
}
});
55 changes: 55 additions & 0 deletions src/components/Results.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
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 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;
},

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="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);
33 changes: 33 additions & 0 deletions src/components/Vote.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import React from 'react';
import {PureRenderMixin} from 'react-addons-pure-render-mixin';

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}
disabled={this.isDisabled()}
onClick={() => this.props.vote(entry) }>
<h1 className="btn-title">{entry}</h1>
{this.hasVotedFor(entry) ?
<div className="label">Voted</div> :
null}
</button>
)}
</div>;
}
});
31 changes: 31 additions & 0 deletions src/components/Voting.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
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);
12 changes: 12 additions & 0 deletions src/components/Winner.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
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>;
}
});
37 changes: 37 additions & 0 deletions src/index.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import React from 'react';
import ReactDOM from 'react-dom';
import {Router, Route, hashHistory} 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';

const socket = io(`${location.protocol}//${location.hostname}:8090`);
socket.on('state', function(state) {
store.dispatch(setState(state));
});

const createStoreWithMiddleware = applyMiddleware(
remoteActionMiddleware(socket)
)(createStore);
const store = createStoreWithMiddleware(reducer);

const routes = <Route component={App}>
<Route path="/results" component={ResultsContainer} />
<Route path="/" component={VotingContainer} />
</Route>;

ReactDOM.render(
<Provider store={store}>
<Router history={hashHistory}>{routes}</Router>
</Provider>,
document.getElementById('app')
);
35 changes: 35 additions & 0 deletions src/reducer.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import {List, Map} from 'immutable';

function setState(state, newState) {
return state.merge(newState);
}

function vote(state, entry) {
const pair = state.getIn(['vote', 'pair']);

if (pair && pair.includes(entry)) {
return state.set('hasVoted', entry);
}
return state;
}

function resetVote(state) {
const hasVoted = state.get('hasVoted');
const pair = state.getIn(['vote', 'pair'], List());

if (hasVoted && !pair.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);
}

return state;
}
13 changes: 13 additions & 0 deletions src/remote_action_middleware.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export default function(socket) {
return function(store) {
return function(next) {
return function(action) {
if (action.meta && action.meta.remote) {
socket.emit('action', action);
}

return next(action);
}
}
}
}
60 changes: 60 additions & 0 deletions test/components/Results_spec.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
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', function() {

it('renders entries with vote counts or zero', function() {
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(function(entry) {
return entry.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 the next button is clicked', function() {
let nextInvoked = false;
const next = function() {
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', function() {
const component = renderIntoDocument(
<Results winner="Trainspotting"
pair={['Trainspotting', '28 Days Later']}
tally={Map()} />
);
const winner = ReactDOM.findDOMNode(component.refs.winner);

expect(winner).to.be.ok;
expect(winner.textContent).to.contain('Trainspotting');
});
});
Loading

0 comments on commit aae4ed2

Please sign in to comment.