├── .gitignore ├── dist └── index.html ├── package.json ├── src ├── action_creators.js ├── client_id.js ├── components │ ├── App.jsx │ ├── ConnectionState.jsx │ ├── Results.jsx │ ├── Vote.jsx │ ├── Voting.jsx │ └── Winner.jsx ├── index.jsx ├── reducer.js ├── remote_action_middleware.js └── style.css ├── test ├── components │ ├── ConnectionState_spec.js │ ├── Results_spec.js │ └── Voting_spec.jsx ├── reducer_spec.js └── test_helper.js └── webpack.config.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist/bundle.js 3 | -------------------------------------------------------------------------------- /dist/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "voting-client", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "mocha --compilers js:babel-core/register --require ./test/test_helper.js \"test/**/*@(.js|.jsx)\"" 8 | }, 9 | "author": "", 10 | "license": "ISC", 11 | "devDependencies": { 12 | "postcss-loader": "0.8.1", 13 | "babel-core": "6.5.1", 14 | "babel-loader": "6.2.2", 15 | "babel-preset-es2015": "6.5.0", 16 | "babel-preset-react": "6.5.0", 17 | "chai": "3.5.0", 18 | "chai-immutable": "1.5.3", 19 | "css-loader": "0.23.1", 20 | "jsdom": "8.0.4", 21 | "mocha": "2.4.5", 22 | "react-hot-loader": "^1.3.0", 23 | "style-loader": "0.13.0", 24 | "webpack": "1.12.14", 25 | "webpack-dev-server": "1.14.1" 26 | }, 27 | "dependencies": { 28 | "autoprefixer": "6.3.3", 29 | "classnames": "2.2.3", 30 | "immutable": "3.7.6", 31 | "object-assign": "4.0.1", 32 | "react": "0.14.7", 33 | "react-addons-pure-render-mixin": "0.14.7", 34 | "react-addons-test-utils": "0.14.7", 35 | "react-dom": "0.14.7", 36 | "react-redux": "4.4.0", 37 | "react-router": "2.0.0", 38 | "redux": "3.3.1", 39 | "socket.io-client": "1.4.5", 40 | "uuid": "2.0.1" 41 | }, 42 | "babel": { 43 | "presets": [ 44 | "es2015", 45 | "react" 46 | ] 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/action_creators.js: -------------------------------------------------------------------------------- 1 | export function setClientId(clientId) { 2 | return { 3 | type: 'SET_CLIENT_ID', 4 | clientId 5 | }; 6 | } 7 | 8 | export function setConnectionState(state, connected) { 9 | return { 10 | type: 'SET_CONNECTION_STATE', 11 | state, 12 | connected 13 | }; 14 | } 15 | 16 | export function setState(state) { 17 | return { 18 | type: 'SET_STATE', 19 | state 20 | }; 21 | } 22 | 23 | export function vote(entry) { 24 | return { 25 | meta: {remote: true}, 26 | type: 'VOTE', 27 | entry 28 | }; 29 | } 30 | 31 | export function next() { 32 | return { 33 | meta: {remote: true}, 34 | type: 'NEXT' 35 | }; 36 | } 37 | 38 | export function restart() { 39 | return { 40 | meta: {remote: true}, 41 | type: 'RESTART' 42 | }; 43 | } 44 | -------------------------------------------------------------------------------- /src/client_id.js: -------------------------------------------------------------------------------- 1 | import uuid from 'uuid'; 2 | 3 | export default function getClientId() { 4 | let id = localStorage.getItem('clientId'); 5 | if (!id) { 6 | id = uuid.v4(); 7 | localStorage.setItem('clientId', id); 8 | } 9 | return id; 10 | } 11 | -------------------------------------------------------------------------------- /src/components/App.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {ConnectionStateContainer} from './ConnectionState'; 3 | 4 | export default React.createClass({ 5 | render: function() { 6 | return
7 | 8 | {this.props.children} 9 |
10 | } 11 | }); 12 | -------------------------------------------------------------------------------- /src/components/ConnectionState.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PureRenderMixin from 'react-addons-pure-render-mixin'; 3 | import {connect} from 'react-redux'; 4 | import {Map} from 'immutable'; 5 | 6 | export const ConnectionState = React.createClass({ 7 | mixins: [PureRenderMixin], 8 | isVisible: function() { 9 | return !this.props.connected; 10 | }, 11 | getMessage: function() { 12 | return `Not connected (${this.props.state})`; 13 | }, 14 | render: function() { 15 | return
17 | {this.getMessage()} 18 |
19 | } 20 | }); 21 | 22 | 23 | export const ConnectionStateContainer = connect( 24 | state => state.get('connection', Map()).toJS() 25 | )(ConnectionState); 26 | -------------------------------------------------------------------------------- /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 VOTE_WIDTH_PERCENT = 8; 8 | 9 | export const Results = React.createClass({ 10 | mixins: [PureRenderMixin], 11 | getPair: function() { 12 | return this.props.pair || []; 13 | }, 14 | getVotes: function(entry) { 15 | if (this.props.tally && this.props.tally.has(entry)) { 16 | return this.props.tally.get(entry); 17 | } 18 | return 0; 19 | }, 20 | getVotesBlockWidth: function(entry) { 21 | return (this.getVotes(entry) * VOTE_WIDTH_PERCENT) + '%'; 22 | }, 23 | render: function() { 24 | return this.props.winner ? 25 | : 26 |
27 |
28 | {this.getPair().map(entry => 29 |
30 |

{entry}

31 |
32 |
34 |
35 |
36 |
37 | {this.getVotes(entry)} 38 |
39 |
40 | )} 41 |
42 |
43 | 47 | 52 |
53 |
; 54 | } 55 | }); 56 | 57 | function mapStateToProps(state) { 58 | return { 59 | pair: state.getIn(['vote', 'pair']), 60 | tally: state.getIn(['vote', 'tally']), 61 | winner: state.get('winner') 62 | } 63 | } 64 | 65 | export const ResultsContainer = connect( 66 | mapStateToProps, 67 | actionCreators 68 | )(Results); 69 | -------------------------------------------------------------------------------- /src/components/Vote.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PureRenderMixin from 'react-addons-pure-render-mixin'; 3 | import classNames from 'classnames' 4 | 5 | export default React.createClass({ 6 | mixins: [PureRenderMixin], 7 | getPair: function() { 8 | return this.props.pair || []; 9 | }, 10 | isDisabled: function() { 11 | return !!this.props.hasVoted; 12 | }, 13 | hasVotedFor: function(entry) { 14 | return this.props.hasVoted === entry; 15 | }, 16 | render: function() { 17 | return
18 | {this.getPair().map(entry => 19 | 28 | )} 29 |
; 30 | } 31 | }); 32 | -------------------------------------------------------------------------------- /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.getIn(['myVote', 'entry']), 23 | winner: state.get('winner') 24 | }; 25 | } 26 | 27 | export const VotingContainer = connect( 28 | mapStateToProps, 29 | actionCreators 30 | )(Voting); 31 | -------------------------------------------------------------------------------- /src/components/Winner.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 | render: function() { 7 | return
8 | Winner is {this.props.winner}! 9 |
; 10 | } 11 | }); 12 | -------------------------------------------------------------------------------- /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 {setClientId, setState, setConnectionState} from './action_creators'; 9 | import remoteActionMiddleware from './remote_action_middleware'; 10 | import getClientId from './client_id'; 11 | import App from './components/App'; 12 | import {VotingContainer} from './components/Voting'; 13 | import {ResultsContainer} from './components/Results'; 14 | 15 | require('./style.css'); 16 | 17 | const socket = io(`${location.protocol}//${location.hostname}:8090`); 18 | socket.on('state', state => 19 | store.dispatch(setState(state)) 20 | ); 21 | [ 22 | 'connect', 23 | 'connect_error', 24 | 'connect_timeout', 25 | 'reconnect', 26 | 'reconnecting', 27 | 'reconnect_error', 28 | 'reconnect_failed' 29 | ].forEach(ev => 30 | socket.on(ev, () => store.dispatch(setConnectionState(ev, socket.connected))) 31 | ); 32 | 33 | const createStoreWithMiddleware = applyMiddleware( 34 | remoteActionMiddleware(socket) 35 | )(createStore); 36 | const store = createStoreWithMiddleware(reducer); 37 | store.dispatch(setClientId(getClientId())); 38 | 39 | const routes = 40 | 41 | 42 | ; 43 | 44 | ReactDOM.render( 45 | 46 | {routes} 47 | , 48 | document.getElementById('app') 49 | ); 50 | -------------------------------------------------------------------------------- /src/reducer.js: -------------------------------------------------------------------------------- 1 | import {List, Map} from 'immutable'; 2 | 3 | function setConnectionState(state, connectionState, connected) { 4 | return state.set('connection', Map({ 5 | state: connectionState, 6 | connected 7 | })); 8 | } 9 | 10 | function setState(state, newState) { 11 | return state.merge(newState); 12 | } 13 | 14 | function vote(state, entry) { 15 | const currentRound = state.getIn(['vote', 'round']); 16 | const currentPair = state.getIn(['vote', 'pair']); 17 | if (currentPair && currentPair.includes(entry)) { 18 | return state.set('myVote', Map({ 19 | round: currentRound, 20 | entry 21 | })); 22 | } else { 23 | return state; 24 | } 25 | } 26 | 27 | function resetVote(state) { 28 | const votedForRound = state.getIn(['myVote', 'round']); 29 | const currentRound = state.getIn(['vote', 'round']); 30 | if (votedForRound !== currentRound) { 31 | return state.remove('myVote'); 32 | } else { 33 | return state; 34 | } 35 | } 36 | 37 | export default function(state = Map(), action) { 38 | switch (action.type) { 39 | case 'SET_CLIENT_ID': 40 | return state.set('clientId', action.clientId); 41 | case 'SET_CONNECTION_STATE': 42 | return setConnectionState(state, action.state, action.connected); 43 | case 'SET_STATE': 44 | return resetVote(setState(state, action.state)); 45 | case 'VOTE': 46 | return vote(state, action.entry); 47 | default: 48 | return state; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/remote_action_middleware.js: -------------------------------------------------------------------------------- 1 | import objectAssign from 'object-assign'; 2 | 3 | export default socket => store => next => action => { 4 | if (action.meta && action.meta.remote) { 5 | const clientId = store.getState().get('clientId'); 6 | socket.emit('action', objectAssign({}, action, {clientId})); 7 | } 8 | return next(action); 9 | } 10 | -------------------------------------------------------------------------------- /src/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: 'Open Sans', sans-serif; 3 | background-color: #673AB7; 4 | color: white; 5 | } 6 | 7 | /* Voting Screen */ 8 | 9 | .voting { 10 | position: fixed; 11 | top: 0; 12 | left: 0; 13 | right: 0; 14 | bottom: 0; 15 | 16 | display: flex; 17 | flex-direction: column; 18 | 19 | user-select: none; 20 | } 21 | 22 | .voting button { 23 | flex: 1 0 0; 24 | 25 | background-color: #673AB7; 26 | border-width: 0; 27 | } 28 | .voting button:first-child { 29 | border-bottom: 1px solid white; 30 | } 31 | .voting button:active { 32 | background-color: white; 33 | color: #311B92; 34 | } 35 | .voting button.voted { 36 | background-color: #311B92; 37 | } 38 | .voting button:not(.voted) .label { 39 | visibility: hidden; 40 | } 41 | .voting button .label { 42 | opacity: 0.87; 43 | } 44 | .voting button.votedAgainst * { 45 | opacity: 0.3; 46 | } 47 | 48 | @media only screen and (min-device-width: 500px) { 49 | .voting { 50 | flex-direction: row; 51 | } 52 | .voting button:first-child { 53 | border-bottom-width: 0; 54 | border-right: 1px solid white; 55 | } 56 | } 57 | 58 | /* Results Screen */ 59 | 60 | .results { 61 | position: fixed; 62 | top: 0; 63 | left: 0; 64 | right: 0; 65 | bottom: 0; 66 | 67 | display: flex; 68 | flex-direction: column; 69 | } 70 | .results .tally { 71 | flex: 1; 72 | 73 | display: flex; 74 | flex-direction: column; 75 | justify-content: center; 76 | } 77 | .results .tally .entry { 78 | display: flex; 79 | justify-content: space-around; 80 | align-items: center; 81 | } 82 | 83 | .results .tally h1 { 84 | width: 25%; 85 | } 86 | .results .tally .voteVisualization { 87 | height: 50px; 88 | width: 50%; 89 | display: flex; 90 | justify-content: flex-start; 91 | 92 | background-color: #7E57C2; 93 | } 94 | .results .tally .votesBlock { 95 | background-color: white; 96 | transition: width 0.5s; 97 | } 98 | .results .tally .voteCount { 99 | font-size: 2rem; 100 | } 101 | 102 | .results .management { 103 | display: flex; 104 | 105 | height: 2em; 106 | border-top: 1px solid #aaa; 107 | } 108 | 109 | .results .management button { 110 | border: 0; 111 | background-color: black; 112 | color: #aaa; 113 | } 114 | .results .management .next { 115 | flex: 1; 116 | } 117 | 118 | /* Winner View */ 119 | 120 | .winner { 121 | font-size: 4rem; 122 | text-align: center; 123 | } 124 | 125 | /* Connection State */ 126 | 127 | .connectionState { 128 | position: fixed; 129 | top: 0; 130 | left: 0; 131 | right: 0; 132 | 133 | padding: 5px; 134 | 135 | text-align: center; 136 | 137 | background-color: #B71C1C; 138 | } 139 | -------------------------------------------------------------------------------- /test/components/ConnectionState_spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | renderIntoDocument, 4 | findRenderedDOMComponentWithTag 5 | } from 'react-addons-test-utils'; 6 | import {expect} from 'chai'; 7 | import {ConnectionState} from '../../src/components/ConnectionState'; 8 | 9 | describe('ConnectionState', () => { 10 | 11 | it('is not visible when connected', () => { 12 | const component = renderIntoDocument(); 13 | const div = findRenderedDOMComponentWithTag(component, 'div'); 14 | expect(div.style.display).to.equal('none'); 15 | }); 16 | 17 | it('is visible when not connected', () => { 18 | const component = renderIntoDocument(); 19 | const div = findRenderedDOMComponentWithTag(component, 'div'); 20 | expect(div.style.display).to.equal('block'); 21 | }); 22 | 23 | it('contains connection state message', () => { 24 | const component = renderIntoDocument( 25 | 26 | ); 27 | const div = findRenderedDOMComponentWithTag(component, 'div'); 28 | expect(div.textContent).to.contain('Fail'); 29 | }); 30 | 31 | }); 32 | -------------------------------------------------------------------------------- /test/components/Results_spec.js: -------------------------------------------------------------------------------- 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 | 13 | 14 | describe('Results', () => { 15 | 16 | it('renders entries with vote counts or zero', () => { 17 | const pair = List.of('Trainspotting', '28 Days Later'); 18 | const tally = Map({'Trainspotting': 5}); 19 | const component = renderIntoDocument( 20 | 21 | ); 22 | const entries = scryRenderedDOMComponentsWithClass(component, 'entry'); 23 | const [train, days] = entries.map(e => e.textContent); 24 | 25 | expect(entries.length).to.equal(2); 26 | expect(train).to.contain('Trainspotting'); 27 | expect(train).to.contain('5'); 28 | expect(days).to.contain('28 Days Later'); 29 | expect(days).to.contain('0'); 30 | }); 31 | 32 | it('invokes action callback when next button is clicked', () => { 33 | let nextInvoked = false; 34 | function next() { nextInvoked = true; } 35 | 36 | const pair = List.of('Trainspotting', '28 Days Later'); 37 | const component = renderIntoDocument( 38 | 41 | ); 42 | Simulate.click(ReactDOM.findDOMNode(component.refs.next)); 43 | 44 | expect(nextInvoked).to.equal(true); 45 | }); 46 | 47 | it('invokes action callback when restart button is clicked', () => { 48 | let restartInvoked = false; 49 | const pair = List.of('Trainspotting', '28 Days Later'); 50 | const component = renderIntoDocument( 51 | restartInvoked = true}/> 54 | ); 55 | Simulate.click(ReactDOM.findDOMNode(component.refs.restart)); 56 | 57 | expect(restartInvoked).to.equal(true); 58 | }); 59 | 60 | it('renders the winner when there is one', () => { 61 | const component = renderIntoDocument( 62 | 65 | ); 66 | const winner = ReactDOM.findDOMNode(component.refs.winner); 67 | expect(winner).to.be.ok; 68 | expect(winner.textContent).to.contain('Trainspotting'); 69 | }); 70 | 71 | }); 72 | -------------------------------------------------------------------------------- /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 | function 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 | 65 | ); 66 | const buttons = scryRenderedDOMComponentsWithTag(component, 'button'); 67 | expect(buttons.length).to.equal(0); 68 | 69 | const winner = ReactDOM.findDOMNode(component.refs.winner); 70 | expect(winner).to.be.ok; 71 | expect(winner.textContent).to.contain('Trainspotting'); 72 | }); 73 | 74 | it('renders as a pure component', () => { 75 | const pair = ['Trainspotting', '28 Days Later']; 76 | const container = document.createElement('div'); 77 | let component = ReactDOM.render( 78 | , 79 | container 80 | ); 81 | 82 | let firstButton = scryRenderedDOMComponentsWithTag(component, 'button')[0]; 83 | expect(firstButton.textContent).to.equal('Trainspotting'); 84 | 85 | pair[0] = 'Sunshine'; 86 | component = ReactDOM.render( 87 | , 88 | container 89 | ); 90 | firstButton = scryRenderedDOMComponentsWithTag(component, 'button')[0]; 91 | expect(firstButton.textContent).to.equal('Trainspotting'); 92 | }); 93 | 94 | it('does update DOM when prop changes', () => { 95 | const pair = List.of('Trainspotting', '28 Days Later'); 96 | const container = document.createElement('div'); 97 | let component = ReactDOM.render( 98 | , 99 | container 100 | ); 101 | 102 | let firstButton = scryRenderedDOMComponentsWithTag(component, 'button')[0]; 103 | expect(firstButton.textContent).to.equal('Trainspotting'); 104 | 105 | const newPair = pair.set(0, 'Sunshine'); 106 | component = ReactDOM.render( 107 | , 108 | container 109 | ); 110 | firstButton = scryRenderedDOMComponentsWithTag(component, 'button')[0]; 111 | expect(firstButton.textContent).to.equal('Sunshine'); 112 | }); 113 | 114 | }); 115 | -------------------------------------------------------------------------------- /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_CLIENT_ID', () => { 9 | const initialState = Map(); 10 | const action = { 11 | type: 'SET_CLIENT_ID', 12 | clientId: '1234' 13 | }; 14 | const nextState = reducer(initialState, action); 15 | 16 | expect(nextState).to.equal(fromJS({ 17 | clientId: '1234' 18 | })); 19 | }); 20 | 21 | it('handles SET_STATE', () => { 22 | const initialState = Map(); 23 | const action = { 24 | type: 'SET_STATE', 25 | state: Map({ 26 | vote: Map({ 27 | pair: List.of('Trainspotting', '28 Days Later'), 28 | tally: Map({Trainspotting: 1}) 29 | }) 30 | }) 31 | }; 32 | const nextState = reducer(initialState, action); 33 | 34 | expect(nextState).to.equal(fromJS({ 35 | vote: { 36 | pair: ['Trainspotting', '28 Days Later'], 37 | tally: {Trainspotting: 1} 38 | } 39 | })); 40 | }); 41 | 42 | it('handles SET_STATE with plain JS payload', () => { 43 | const initialState = Map(); 44 | const action = { 45 | type: 'SET_STATE', 46 | state: { 47 | vote: { 48 | pair: ['Trainspotting', '28 Days Later'], 49 | tally: {Trainspotting: 1} 50 | } 51 | } 52 | }; 53 | const nextState = reducer(initialState, action); 54 | 55 | expect(nextState).to.equal(fromJS({ 56 | vote: { 57 | pair: ['Trainspotting', '28 Days Later'], 58 | tally: {Trainspotting: 1} 59 | } 60 | })); 61 | }); 62 | 63 | it('handles SET_STATE without initial state', () => { 64 | const action = { 65 | type: 'SET_STATE', 66 | state: { 67 | vote: { 68 | pair: ['Trainspotting', '28 Days Later'], 69 | tally: {Trainspotting: 1} 70 | } 71 | } 72 | }; 73 | const nextState = reducer(undefined, action); 74 | 75 | expect(nextState).to.equal(fromJS({ 76 | vote: { 77 | pair: ['Trainspotting', '28 Days Later'], 78 | tally: {Trainspotting: 1} 79 | } 80 | })); 81 | }); 82 | 83 | it('handles VOTE by setting myVote', () => { 84 | const state = fromJS({ 85 | vote: { 86 | round: 42, 87 | pair: ['Trainspotting', '28 Days Later'], 88 | tally: {Trainspotting: 1} 89 | } 90 | }); 91 | const action = {type: 'VOTE', entry: 'Trainspotting'}; 92 | const nextState = reducer(state, action); 93 | 94 | expect(nextState).to.equal(fromJS({ 95 | vote: { 96 | round: 42, 97 | pair: ['Trainspotting', '28 Days Later'], 98 | tally: {Trainspotting: 1} 99 | }, 100 | myVote: { 101 | round: 42, 102 | entry: 'Trainspotting' 103 | } 104 | })); 105 | }); 106 | 107 | it('does not set myVote for VOTE on invalid entry', () => { 108 | const state = fromJS({ 109 | vote: { 110 | round: 42, 111 | pair: ['Trainspotting', '28 Days Later'], 112 | tally: {Trainspotting: 1} 113 | } 114 | }); 115 | const action = {type: 'VOTE', entry: 'Sunshine'}; 116 | const nextState = reducer(state, action); 117 | 118 | expect(nextState).to.equal(fromJS({ 119 | vote: { 120 | round: 42, 121 | pair: ['Trainspotting', '28 Days Later'], 122 | tally: {Trainspotting: 1} 123 | } 124 | })); 125 | }); 126 | 127 | it('removes myVote on SET_STATE if round has changed', () => { 128 | const initialState = fromJS({ 129 | vote: { 130 | round: 42, 131 | pair: ['Trainspotting', '28 Days Later'], 132 | tally: {Trainspotting: 1} 133 | }, 134 | myVote: { 135 | round: 42, 136 | entry: 'Trainspotting' 137 | } 138 | }); 139 | const action = { 140 | type: 'SET_STATE', 141 | state: { 142 | vote: { 143 | round: 43, 144 | pair: ['Sunshine', 'Trainspotting'] 145 | } 146 | } 147 | }; 148 | const nextState = reducer(initialState, action); 149 | 150 | expect(nextState).to.equal(fromJS({ 151 | vote: { 152 | round: 43, 153 | pair: ['Sunshine', 'Trainspotting'] 154 | } 155 | })); 156 | }); 157 | 158 | }); 159 | -------------------------------------------------------------------------------- /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 | // from mocha-jsdom https://github.com/rstacruz/mocha-jsdom/blob/master/index.js#L80 12 | Object.keys(window).forEach((key) => { 13 | if (!(key in global)) { 14 | global[key] = window[key]; 15 | } 16 | }); 17 | 18 | 19 | chai.use(chaiImmutable); 20 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | var webpack = require('webpack'); 2 | var autoprefixer = require('autoprefixer'); 3 | 4 | module.exports = { 5 | entry: [ 6 | 'webpack-dev-server/client?http://localhost:8080', 7 | 'webpack/hot/only-dev-server', 8 | './src/index.jsx' 9 | ], 10 | module: { 11 | loaders: [{ 12 | test: /\.jsx?$/, 13 | exclude: /node_modules/, 14 | loader: 'react-hot!babel' 15 | }, { 16 | test: /\.css$/, 17 | loader: 'style!css!postcss' 18 | }] 19 | }, 20 | resolve: { 21 | extensions: ['', '.js', '.jsx'] 22 | }, 23 | output: { 24 | path: __dirname + '/dist', 25 | publicPath: '/', 26 | filename: 'bundle.js' 27 | }, 28 | devServer: { 29 | contentBase: './dist', 30 | hot: true 31 | }, 32 | plugins: [ 33 | new webpack.HotModuleReplacementPlugin() 34 | ], 35 | postcss: function () { 36 | return [autoprefixer]; 37 | } 38 | }; 39 | --------------------------------------------------------------------------------