├── README.md
├── dist
├── index.html
└── bundle.js
├── src
├── components
│ ├── App.jsx
│ ├── Winner.jsx
│ ├── Voting.jsx
│ ├── Vote.jsx
│ └── Results.jsx
├── remote_action_middleware.js
├── action_creators.js
├── reducer.js
└── index.jsx
├── test
├── test_helper.js
├── components
│ ├── Results_spec.jsx
│ └── Voting_spec.jsx
└── reducer_spec.js
├── webpack.config.js
└── package.json
/README.md:
--------------------------------------------------------------------------------
1 | the client side of a voting application
2 |
--------------------------------------------------------------------------------
/dist/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/src/components/App.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | export default React.createClass({
4 | render: function() {
5 | return this.props.children;
6 | }
7 | });
8 |
9 |
--------------------------------------------------------------------------------
/src/remote_action_middleware.js:
--------------------------------------------------------------------------------
1 | export default socket => store => next => action => {
2 | if (action.meta && action.meta.remote) {
3 | socket.emit('action', action);
4 | }
5 | return next(action);
6 | }
7 |
8 |
--------------------------------------------------------------------------------
/src/components/Winner.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | export default React.createClass({
4 | render: function() {
5 | return
6 | Winner is {this.props.winner}!
7 |
;
8 | }
9 | });
10 |
11 |
--------------------------------------------------------------------------------
/src/action_creators.js:
--------------------------------------------------------------------------------
1 | export function setState(state) {
2 | return {
3 | type: 'SET_STATE',
4 | state
5 | };
6 | }
7 |
8 | export function vote(entry) {
9 | return {
10 | meta: {remote: true},
11 | type: 'VOTE',
12 | entry
13 | };
14 | }
15 |
16 |
--------------------------------------------------------------------------------
/test/test_helper.js:
--------------------------------------------------------------------------------
1 | import jsdom from 'jsdom';
2 | import chai from 'chai';
3 | import chaiImmutable from 'chai-immutable';
4 |
5 | const doc = jsdom.jsdom('');
6 | const win = doc.defaultView;
7 |
8 | global.document = doc;
9 | global.window = win;
10 |
11 | Object.keys(window).forEach((key) => {
12 | if (!(key in global)) {
13 | global[key] = window[key];
14 | }
15 | });
16 |
17 | chai.use(chaiImmutable);
18 |
--------------------------------------------------------------------------------
/webpack.config.js:
--------------------------------------------------------------------------------
1 | var webpack = require('webpack');
2 |
3 | module.exports = {
4 | entry: [
5 | 'webpack-dev-server/client?http://localhost:8080',
6 | 'webpack/hot/only-dev-server',
7 | './src/index.jsx'
8 | ],
9 | module: {
10 | loaders:[{
11 | test: /\.jsx?$/,
12 | exclude: /node_modules/,
13 | loader: 'react-hot!babel'
14 | }]
15 | },
16 | resolve: {
17 | extensions: ['', '.js', '.jsx']
18 | },
19 | output: {
20 | path: __dirname + '/dist',
21 | publicPath: '/',
22 | filename: 'bundle.js'
23 | },
24 | devServer: {
25 | contentBase: './dist',
26 | hot: true
27 | },
28 | plugins: [
29 | new webpack.HotModuleReplacementPlugin()
30 | ]
31 | };
32 |
33 |
--------------------------------------------------------------------------------
/src/components/Voting.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PureRenderMixin from 'react-addons-pure-render-mixin';
3 | import {connect} from 'react-redux';
4 | import Winner from './Winner';
5 | import Vote from './Vote';
6 | import * as actionCreators from '../action_creators';
7 |
8 | export const Voting = React.createClass({
9 | mixins: [PureRenderMixin],
10 | render: function() {
11 | return
12 | {this.props.winner ?
13 | :
14 | }
15 |
;
16 | }
17 | });
18 |
19 | function mapStateToProps(state) {
20 | return {
21 | pair: state.getIn(['vote', 'pair']),
22 | hasVoted: state.get('hasVoted'),
23 | winner: state.get('winner')
24 | };
25 | }
26 |
27 | export const VotingContainer = connect(
28 | mapStateToProps,
29 | actionCreators
30 | )(Voting);
31 |
32 |
--------------------------------------------------------------------------------
/src/components/Vote.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PureRenderMixin from 'react-addons-pure-render-mixin';
3 |
4 | export default React.createClass({
5 | mixins: [PureRenderMixin],
6 | getPair: function() {
7 | return this.props.pair || [];
8 | },
9 | isDisabled: function() {
10 | return !!this.props.hasVoted;
11 | },
12 | hasVotedFor: function(entry) {
13 | return this.props.hasVoted === entry;
14 | },
15 | render: function() {
16 | return
17 | {this.getPair().map(entry =>
18 |
26 | )}
27 |
;
28 | }
29 | });
30 |
31 |
--------------------------------------------------------------------------------
/src/reducer.js:
--------------------------------------------------------------------------------
1 | import {List, Map} from 'immutable';
2 |
3 | function setState(state, newState) {
4 | return state.merge(newState);
5 | }
6 |
7 | function vote(state, entry) {
8 | const currentPair = state.getIn(['vote', 'pair']);
9 | if (currentPair && currentPair.includes(entry)) {
10 | return state.set('hasVoted', entry);
11 | } else {
12 | return state;
13 | }
14 | }
15 |
16 | function resetVote(state) {
17 | const hasVoted = state.get('hasVoted');
18 | const currentPair = state.getIn(['vote', 'pair'], List());
19 | if (hasVoted && !currentPair.includes(hasVoted)) {
20 | return state.remove('hasVoted');
21 | } else {
22 | return state;
23 | }
24 | }
25 |
26 | export default function(state = Map(), action) {
27 | switch (action.type) {
28 | case 'SET_STATE':
29 | return resetVote(setState(state, action.state));
30 | case 'VOTE':
31 | return vote(state, action.entry);
32 | }
33 | return state;
34 | }
35 |
36 |
--------------------------------------------------------------------------------
/src/index.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import {Router, Route, hashHistory} from 'react-router';
4 | import {createStore, applyMiddleware} from 'redux';
5 | import {Provider} from 'react-redux';
6 | import io from 'socket.io-client';
7 | import reducer from './reducer';
8 | import {setState} from './action_creators';
9 | import remoteActionMiddleware from './remote_action_middleware';
10 | import App from './components/App';
11 | import {VotingContainer} from './components/Voting';
12 | import {ResultsContainer} from './components/Results';
13 |
14 | const socket = io(`${location.protocol}//${location.hostname}:8090`);
15 | socket.on('state', state =>
16 | store.dispatch(setState(state))
17 | );
18 |
19 | const createStoreWithMiddleware = applyMiddleware(
20 | remoteActionMiddleware(socket)
21 | )(createStore);
22 | const store = createStoreWithMiddleware(reducer);
23 |
24 | const routes =
25 |
26 |
27 | ;
28 |
29 | ReactDOM.render(
30 |
31 | {routes}
32 | ,
33 | document.getElementById('app')
34 | );
35 |
36 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "voting-client",
3 | "version": "1.0.0",
4 | "description": "the client side of a voting application",
5 | "main": "index.js",
6 | "scripts": {
7 | "test": "mocha --compilers js:babel-core/register --require ./test/test_helper.js \"test/**/*@(.js|.jsx)\"",
8 | "rest:watch": "npm run test -- --watch"
9 | },
10 | "repository": {
11 | "type": "git",
12 | "url": "git+https://github.com/CaptainStack/voting-client.git"
13 | },
14 | "keywords": [],
15 | "author": "",
16 | "license": "ISC",
17 | "bugs": {
18 | "url": "https://github.com/CaptainStack/voting-client/issues"
19 | },
20 | "homepage": "https://github.com/CaptainStack/voting-client#readme",
21 | "devDependencies": {
22 | "babel-core": "^6.21.0",
23 | "babel-loader": "^6.2.10",
24 | "babel-preset-es2015": "^6.18.0",
25 | "babel-preset-react": "^6.16.0",
26 | "chai": "^3.5.0",
27 | "chai-immutable": "^1.6.0",
28 | "jsdom": "^9.9.1",
29 | "mocha": "^3.2.0",
30 | "react-hot-loader": "^1.3.1",
31 | "webpack": "^1.14.0",
32 | "webpack-dev-server": "^1.16.2"
33 | },
34 | "babel": {
35 | "presets": [
36 | "es2015",
37 | "react"
38 | ]
39 | },
40 | "dependencies": {
41 | "immutable": "^3.8.1",
42 | "react": "^15.4.1",
43 | "react-addons-pure-render-mixin": "^15.4.1",
44 | "react-addons-test-utils": "^15.4.1",
45 | "react-dom": "^15.4.1",
46 | "react-redux": "^5.0.1",
47 | "react-router": "^2.0.0",
48 | "redux": "^3.6.0",
49 | "socket.io-client": "^1.7.2"
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/test/components/Results_spec.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import {
4 | renderIntoDocument,
5 | scryRenderedDOMComponentsWithClass,
6 | Simulate
7 | } from 'react-addons-test-utils';
8 | import {List, Map} from 'immutable';
9 | import {Results} from '../../src/components/Results';
10 | import {expect} from 'chai';
11 |
12 | describe('Results', () => {
13 |
14 | it('renders entries with vote counts or zero', () => {
15 | const pair = List.of('Trainspotting', '28 Days Later');
16 | const tally = Map({'Trainspotting': 5});
17 | const component = renderIntoDocument(
18 |
19 | );
20 | const entries = scryRenderedDOMComponentsWithClass(component, 'entry');
21 | const [train, days] = entries.map(e => e.textContent);
22 |
23 | expect(entries.length).to.equal(2);
24 | expect(train).to.contain('Trainspotting');
25 | expect(train).to.contain('5');
26 | expect(days).to.contain('28 Days Later');
27 | expect(days).to.contain('0');
28 | });
29 |
30 | it('invokes the next callback when next button is clicked', () => {
31 | let nextInvoked = false;
32 | const next = () => nextInvoked = true;
33 |
34 | const pair = List.of('Trainspotting', '28 Days Later');
35 | const component = renderIntoDocument(
36 |
39 | );
40 | Simulate.click(ReactDOM.findDOMNode(component.refs.next));
41 |
42 | expect(nextInvoked).to.equal(true);
43 | });
44 |
45 | });
46 |
47 |
--------------------------------------------------------------------------------
/src/components/Results.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PureRenderMixin from 'react-addons-pure-render-mixin';
3 | import {connect} from 'react-redux';
4 | import Winner from './Winner';
5 | import * as actionCreators from '../action_creators';
6 |
7 | export const Results = React.createClass({
8 | mixins: [PureRenderMixin],
9 | getPair: function() {
10 | return this.props.pair || [];
11 | },
12 | getVotes: function(entry) {
13 | if (this.props.tally && this.props.tally.has(entry)) {
14 | return this.props.tally.get(entry);
15 | }
16 | return 0;
17 | },
18 | render: function() {
19 | return this.props.winner ?
20 | :
21 |
22 |
23 | {this.getPair().map(entry =>
24 |
25 |
{entry}
26 |
27 | {this.getVotes(entry)}
28 |
29 |
30 | )}
31 |
32 |
33 |
38 |
39 |
;
40 | }
41 | });
42 |
43 | function mapStateToProps(state) {
44 | return {
45 | pair: state.getIn(['vote', 'pair']),
46 | tally: state.getIn(['vote', 'tally']),
47 | winner: state.get('winner')
48 | }
49 | }
50 |
51 | export const ResultsContainer = connect(
52 | mapStateToProps,
53 | actionCreators
54 | )(Results);
55 |
56 |
--------------------------------------------------------------------------------
/dist/bundle.js:
--------------------------------------------------------------------------------
1 | /******/ (function(modules) { // webpackBootstrap
2 | /******/ // The module cache
3 | /******/ var installedModules = {};
4 |
5 | /******/ // The require function
6 | /******/ function __webpack_require__(moduleId) {
7 |
8 | /******/ // Check if module is in cache
9 | /******/ if(installedModules[moduleId])
10 | /******/ return installedModules[moduleId].exports;
11 |
12 | /******/ // Create a new module (and put it into the cache)
13 | /******/ var module = installedModules[moduleId] = {
14 | /******/ exports: {},
15 | /******/ id: moduleId,
16 | /******/ loaded: false
17 | /******/ };
18 |
19 | /******/ // Execute the module function
20 | /******/ modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
21 |
22 | /******/ // Flag the module as loaded
23 | /******/ module.loaded = true;
24 |
25 | /******/ // Return the exports of the module
26 | /******/ return module.exports;
27 | /******/ }
28 |
29 |
30 | /******/ // expose the modules object (__webpack_modules__)
31 | /******/ __webpack_require__.m = modules;
32 |
33 | /******/ // expose the module cache
34 | /******/ __webpack_require__.c = installedModules;
35 |
36 | /******/ // __webpack_public_path__
37 | /******/ __webpack_require__.p = "/";
38 |
39 | /******/ // Load entry module and return exports
40 | /******/ return __webpack_require__(0);
41 | /******/ })
42 | /************************************************************************/
43 | /******/ ([
44 | /* 0 */
45 | /***/ function(module, exports, __webpack_require__) {
46 |
47 | module.exports = __webpack_require__(1);
48 |
49 |
50 | /***/ },
51 | /* 1 */
52 | /***/ function(module, exports) {
53 |
54 | console.log('I am alive!');
55 |
56 |
57 | /***/ }
58 | /******/ ]);
--------------------------------------------------------------------------------
/test/reducer_spec.js:
--------------------------------------------------------------------------------
1 | import {List, Map, fromJS} from 'immutable';
2 | import {expect} from 'chai';
3 |
4 | import reducer from '../src/reducer';
5 |
6 | describe('reducer', () => {
7 |
8 | it('handles SET_STATE', () => {
9 | const initialState = Map();
10 | const action = {
11 | type: 'SET_STATE',
12 | state: Map({
13 | vote: Map({
14 | pair: List.of('Trainspotting', '28 Days Later'),
15 | tally: Map({Trainspotting: 1})
16 | })
17 | })
18 | };
19 | const nextState = reducer(initialState, action);
20 |
21 | expect(nextState).to.equal(fromJS({
22 | vote: {
23 | pair: ['Trainspotting', '28 Days Later'],
24 | tally: {Trainspotting: 1}
25 | }
26 | }));
27 | });
28 |
29 | it('handles SET_STATE with plain JS payload', () => {
30 | const initialState=Map();
31 | const action = {
32 | type: 'SET_STATE',
33 | state: {
34 | vote: {
35 | pair: ['Trainspotting', '28 Days Later'],
36 | tally: {Trainspotting: 1}
37 | }
38 | }
39 | };
40 | const nextState = reducer(initialState, action);
41 |
42 | expect(nextState).to.equal(fromJS({
43 | vote: {
44 | pair: ['Trainspotting', '28 Days Later'],
45 | tally: {Trainspotting: 1}
46 | }
47 | }));
48 | });
49 |
50 | it('handles SET_STATE without initial state', () => {
51 | const action = {
52 | type: 'SET_STATE',
53 | state: {
54 | vote: {
55 | pair: ['Trainspotting', '28 Days Later'],
56 | tally: {Trainspotting: 1}
57 | }
58 | }
59 | };
60 | const nextState = reducer(undefined, action);
61 |
62 | expect(nextState).to.equal(fromJS({
63 | vote: {
64 | pair: ['Trainspotting', '28 Days Later'],
65 | tally: {Trainspotting: 1}
66 | }
67 | }));
68 | });
69 |
70 | it('handles VOTE by setting hasVoted', () => {
71 | const state = fromJS({
72 | vote: {
73 | pair: ['Trainspotting', '28 Days Later'],
74 | tally: {Trainspotting: 1}
75 | }
76 | });
77 | const action = {type: 'VOTE', entry: 'Trainspotting'};
78 | const nextState = reducer(state, action);
79 |
80 | expect(nextState).to.equal(fromJS({
81 | vote: {
82 | pair: ['Trainspotting', '28 Days Later'],
83 | tally: {Trainspotting: 1}
84 | },
85 | hasVoted: 'Trainspotting'
86 | }));
87 | });
88 |
89 | it('does not set hasVoted for VOTE on invalid entry', () => {
90 | const state = fromJS({
91 | vote: {
92 | pair: ['Trainspotting', '28 Days Later'],
93 | tally: {Trainspotting: 1}
94 | }
95 | });
96 | const action = {type: 'VOTE', entry: 'Sunshine'};
97 | const nextState = reducer(state, action);
98 |
99 | expect(nextState).to.equal(fromJS({
100 | vote: {
101 | pair: ['Trainspotting', '28 Days Later'],
102 | tally: {Trainspotting: 1}
103 | }
104 | }));
105 | });
106 |
107 | it('removes hasVoted on SET_STATE if pair changes', () => {
108 | const initialState = fromJS({
109 | vote: {
110 | pair: ['Trainspotting', '28 Days Later'],
111 | tally: {Trainspotting: 1}
112 | },
113 | hasVoted: 'Trainspotting'
114 | });
115 | const action = {
116 | type: 'SET_STATE',
117 | state: {
118 | vote: {
119 | pair: ['Sunshine', 'Slumdog Millionaire']
120 | }
121 | }
122 | };
123 | const nextState = reducer(initialState, action);
124 |
125 | expect(nextState).to.equal(fromJS({
126 | vote: {
127 | pair: ['Sunshine', 'Slumdog Millionaire']
128 | }
129 | }));
130 | });
131 |
132 | });
133 |
134 |
--------------------------------------------------------------------------------
/test/components/Voting_spec.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import {
4 | renderIntoDocument,
5 | scryRenderedDOMComponentsWithTag,
6 | Simulate
7 | } from 'react-addons-test-utils';
8 | import {List} from 'immutable';
9 | import {Voting} from '../../src/components/Voting';
10 | import {expect} from 'chai';
11 |
12 | describe('Voting', () => {
13 |
14 | it('renders a pair of buttons', () => {
15 | const component = renderIntoDocument(
16 |
17 | );
18 | const buttons = scryRenderedDOMComponentsWithTag(component, 'button');
19 |
20 | expect(buttons.length).to.equal(2);
21 | expect(buttons[0].textContent).to.equal('Trainspotting');
22 | expect(buttons[1].textContent).to.equal('28 Days Later');
23 | });
24 |
25 | it('invokes callback when a button is clicked', () => {
26 | let votedWith;
27 | const vote = (entry) => votedWith = entry;
28 |
29 | const component = renderIntoDocument(
30 |
32 | );
33 | const buttons = scryRenderedDOMComponentsWithTag(component, 'button');
34 | Simulate.click(buttons[0]);
35 |
36 | expect(votedWith).to.equal('Trainspotting');
37 | });
38 |
39 | it('disables buttons when user has voted', () => {
40 | const component = renderIntoDocument(
41 |
43 | );
44 | const buttons = scryRenderedDOMComponentsWithTag(component, 'button');
45 |
46 | expect(buttons.length).to.equal(2);
47 | expect(buttons[0].hasAttribute('disabled')).to.equal(true);
48 | expect(buttons[1].hasAttribute('disabled')).to.equal(true);
49 | });
50 |
51 | it('adds label to the voted entry', () => {
52 | const component = renderIntoDocument(
53 |
55 | );
56 | const buttons = scryRenderedDOMComponentsWithTag(component, 'button');
57 |
58 | expect(buttons[0].textContent).to.contain('Voted');
59 | });
60 |
61 | it('renders just the winner when there is one', () => {
62 | const component = renderIntoDocument(
63 |
64 | );
65 | const buttons = scryRenderedDOMComponentsWithTag(component, 'button');
66 | expect(buttons.length).to.equal(0);
67 |
68 | const winner = ReactDOM.findDOMNode(component.refs.winner);
69 | expect(winner).to.be.ok;
70 | expect(winner.textContent).to.contain('Trainspotting');
71 | });
72 |
73 | it('renders as a pure component', () => {
74 | const pair = ['Trainspotting', '28 Days Later'];
75 | const container = document.createElement('div');
76 | let component = ReactDOM.render(
77 | ,
78 | container
79 | );
80 |
81 | let firstButton = scryRenderedDOMComponentsWithTag(component, 'button')[0];
82 | expect(firstButton.textContent).to.equal('Trainspotting');
83 |
84 | pair[0] = 'Sunshine';
85 | component = ReactDOM.render(
86 | ,
87 | container
88 | );
89 | firstButton = scryRenderedDOMComponentsWithTag(component, 'button')[0];
90 | expect(firstButton.textContent).to.equal('Trainspotting');
91 | });
92 |
93 | it('does update DOM when prop changes', () => {
94 | const pair = List.of('Trainspotting', '28 Days Later');
95 | const container = document.createElement('div');
96 | let component = ReactDOM.render(
97 | ,
98 | container
99 | );
100 |
101 | let firstButton = scryRenderedDOMComponentsWithTag(component, 'button')[0];
102 | expect(firstButton.textContent).to.equal('Trainspotting');
103 |
104 | const newPair = pair.set(0, 'Sunshine');
105 | component = ReactDOM.render(
106 | ,
107 | container
108 | );
109 | firstButton = scryRenderedDOMComponentsWithTag(component, 'button')[0];
110 | expect(firstButton.textContent).to.equal('Sunshine');
111 | });
112 |
113 | });
114 |
--------------------------------------------------------------------------------