├── 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 | --------------------------------------------------------------------------------