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