├── README.md ├── test ├── test_helper.js ├── store_spec.js ├── immutable_spec.js ├── reducer_spec.js └── core_spec.js ├── src ├── store.js ├── server.js ├── reducer.js └── core.js ├── entries.json ├── index.js └── package.json /README.md: -------------------------------------------------------------------------------- 1 | # voting-server 2 | React/Redux/Immutable tutorial 3 | -------------------------------------------------------------------------------- /test/test_helper.js: -------------------------------------------------------------------------------- 1 | import chai from 'chai'; 2 | import chaiImmutable from 'chai-immutable'; 3 | 4 | chai.use(chaiImmutable); 5 | 6 | -------------------------------------------------------------------------------- /src/store.js: -------------------------------------------------------------------------------- 1 | import {createStore} from 'redux'; 2 | import reducer from './reducer'; 3 | 4 | export default function makeStore() { 5 | return createSTore(reducer); 6 | } 7 | 8 | -------------------------------------------------------------------------------- /entries.json: -------------------------------------------------------------------------------- 1 | [ 2 | "Shallow Grave", 3 | "Trainspotting", 4 | "A Life Less Ordinary", 5 | "The Beach", 6 | "28 Days Later", 7 | "Millions", 8 | "Sunshine", 9 | "Slumdog Millionaire", 10 | "127 Hours", 11 | "Trance", 12 | "Steve Jobs" 13 | ] 14 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | import makeStore from './src/store'; 2 | import {startServer} from './src/server'; 3 | 4 | export const store = makeStore(); 5 | startServer(store); 6 | 7 | store.dispatch({ 8 | type: 'SET_ENTRIES', 9 | entries: require('./entries.json') 10 | }); 11 | store.dispatch({type: 'NEXT'}); 12 | -------------------------------------------------------------------------------- /src/server.js: -------------------------------------------------------------------------------- 1 | import Server from 'socket.io'; 2 | 3 | export function startServer(store) { 4 | const io = new Server().attach(8090); 5 | 6 | store.subscribe( 7 | () => io.emit('state', store.getState().toJS()) 8 | ); 9 | 10 | io.on('connection', (socket) => { 11 | socket.emit('state', store.getState().toJS()); 12 | socket.on('action', store.dispatch.bind(store)); 13 | }); 14 | 15 | } 16 | 17 | -------------------------------------------------------------------------------- /src/reducer.js: -------------------------------------------------------------------------------- 1 | import {setEntries, next, vote, INITIAL_STATE} from './core'; 2 | 3 | export default function reducer(state = INITIAL_STATE, action) { 4 | switch (action.type) { 5 | case 'SET_ENTRIES': 6 | return setEntries(state, action.entries); 7 | case 'NEXT': 8 | return next(state); 9 | case 'VOTE': 10 | return state.update('vote', 11 | voteState => vote(voteState, action.entry)); 12 | } 13 | return state; 14 | } 15 | -------------------------------------------------------------------------------- /test/store_spec.js: -------------------------------------------------------------------------------- 1 | import {Map, fromJS} from 'immutable'; 2 | import {expect} from 'chai'; 3 | 4 | import makeStore from '../src/store'; 5 | 6 | describe('store', () => { 7 | 8 | it('is a Redux store configured with the correct reducer', () => { 9 | const store = makeStore(); 10 | expect(store.getState()).to.equal(Map()); 11 | 12 | store.dispatch({ 13 | type: 'SET_ENTRIES', 14 | entries: ['Trainspotting', '28 Days Later'] 15 | }); 16 | expect(store.getState()).to.equal(fromJS({ 17 | entries: ['Trainspotting', '28 Days Later'] 18 | })); 19 | }); 20 | 21 | }); 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "voting-server", 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 --recursive", 8 | "test:watch": "npm run test -- --watch" 9 | }, 10 | "babel": { 11 | "presets": [ 12 | "es2015" 13 | ] 14 | }, 15 | "keywords": [], 16 | "author": "", 17 | "license": "ISC", 18 | "devDependencies": { 19 | "babel-cli": "^6.18.0", 20 | "babel-core": "^6.21.0", 21 | "babel-preset-es2015": "^6.18.0", 22 | "chai": "^3.5.0", 23 | "chai-immutable": "^1.6.0", 24 | "mocha": "^3.2.0" 25 | }, 26 | "dependencies": { 27 | "immutable": "^3.8.1", 28 | "redux": "^3.6.0", 29 | "socket.io": "^1.7.2" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /test/immutable_spec.js: -------------------------------------------------------------------------------- 1 | import {expect} from 'chai'; 2 | import {List, Map} from 'immutable'; 3 | 4 | describe('immutability', () => { 5 | 6 | describe('a tree', () => { 7 | 8 | function addMovie(currentState, movie) { 9 | return currentState.update('movies', movies => movies.push(movie)); 10 | } 11 | 12 | it('is immutable', () => { 13 | let state = Map({ 14 | movies: List.of('Trainspotting', '28 Days Later') 15 | }); 16 | let nextState = addMovie(state, 'Sunshine'); 17 | 18 | expect(nextState).to.equal(Map({ 19 | movies: List.of( 20 | 'Trainspotting', 21 | '28 Days Later', 22 | 'Sunshine' 23 | ) 24 | })); 25 | expect(state).to.equal(Map({ 26 | movies: List.of( 27 | 'Trainspotting', 28 | '28 Days Later' 29 | ) 30 | })); 31 | }); 32 | 33 | }); 34 | 35 | }); 36 | -------------------------------------------------------------------------------- /src/core.js: -------------------------------------------------------------------------------- 1 | import {List, Map} from 'immutable'; 2 | 3 | export function setEntries(state, entries) { 4 | return state.set('entries', List(entries)); 5 | } 6 | function getWinners(vote) { 7 | if (!vote) return []; 8 | const [a, b] = vote.get('pair'); 9 | const aVotes = vote.getIn(['tally', a], 0); 10 | const bVotes = vote.getIn(['tally', b], 0); 11 | if (aVotes > bVotes) return [a]; 12 | else if (aVotes < bVotes) return [b]; 13 | else return [a, b]; 14 | } 15 | 16 | export function next(state) { 17 | const entries = state.get('entries').concat(getWinners(state.get('vote'))); 18 | if (entries.size ===1) { 19 | return state.remove('vote') 20 | .remove('entries') 21 | .set('winner', entries.first()); 22 | } else { 23 | return state.merge({ 24 | vote: Map({pair: entries.take(2)}), 25 | entries: entries.skip(2) 26 | }); 27 | } 28 | } 29 | 30 | export function vote(voteState, entry) { 31 | return voteState.updateIn( 32 | ['tally', entry], 33 | 0, 34 | tally => tally + 1 35 | ); 36 | } 37 | 38 | export const INITIAL_STATE = Map(); 39 | -------------------------------------------------------------------------------- /test/reducer_spec.js: -------------------------------------------------------------------------------- 1 | import {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_ENTRIES', () => { 9 | const initialState = Map(); 10 | const action = {type: 'SET_ENTRIES', entries: ['Trainspotting']}; 11 | const nextState = reducer(initialState, action); 12 | 13 | expect(nextState).to.equal(fromJS({ 14 | entries: ['Trainspotting'] 15 | })); 16 | }); 17 | 18 | it('handles NEXT', () => { 19 | const initialState = fromJS({ 20 | entries: ['Trainspotting', '28 Days Later'] 21 | }); 22 | const action = {type: 'NEXT'}; 23 | const nextState = reducer(initialState, action); 24 | 25 | expect(nextState).to.equal(fromJS({ 26 | vote: { 27 | pair: ['Trainspotting', '28 Days Later'] 28 | }, 29 | entries: [] 30 | })); 31 | }); 32 | 33 | it('handles VOTE', () => { 34 | const initialState = fromJS({ 35 | vote: { 36 | pair: ['Trainspotting', '28 Days Later'] 37 | }, 38 | entries: [] 39 | }); 40 | const action = {type: 'VOTE', entry: 'Trainspotting'}; 41 | const nextState = reducer(initialState, action); 42 | 43 | expect(nextState).to.equal(fromJS({ 44 | vote: { 45 | pair: ['Trainspotting', '28 Days Later'], 46 | tally: {Trainspotting: 1} 47 | }, 48 | entries: [] 49 | })); 50 | }); 51 | 52 | it('has an initial state', () => { 53 | const action = {type: 'SET_ENTRIES', entries: ['Trainspotting']}; 54 | const nextState = reducer(undefined, action); 55 | expect(nextState).to.equal(fromJS({ 56 | entries: ['Trainspotting'] 57 | })); 58 | }); 59 | 60 | it('can be used with reduce', () => { 61 | const actions = [ 62 | {type: 'SET_ENTRIES', entries: ['Trainspotting', '28 Days Later']}, 63 | {type: 'NEXT'}, 64 | {type: 'VOTE', entry: 'Trainspotting'}, 65 | {type: 'VOTE', entry: '28 Days Later'}, 66 | {type: 'VOTE', entry: 'Trainspotting'}, 67 | {type: 'NEXT'} 68 | ]; 69 | const finalState = actions.reduce(reducer, Map()); 70 | 71 | expect(finalState).to.equal(fromJS({ 72 | winner: 'Trainspotting' 73 | })); 74 | }); 75 | 76 | }); 77 | 78 | -------------------------------------------------------------------------------- /test/core_spec.js: -------------------------------------------------------------------------------- 1 | import {List, Map} from 'immutable'; 2 | import {expect} from 'chai'; 3 | import {setEntries, next, vote} from '../src/core'; 4 | 5 | describe('application logic', () => { 6 | 7 | describe('setEntries', () => { 8 | 9 | it('converts to immutable', () => { 10 | const state = Map(); 11 | const entries = ['Trainspotting', '28 Days Later']; 12 | const nextState = setEntries(state, entries); 13 | expect(nextState).to.equal(Map({ 14 | entries: List.of('Trainspotting', '28 Days Later') 15 | })); 16 | }); 17 | 18 | }); 19 | 20 | describe('next', () => { 21 | 22 | it('takes the next two entries under vote', () => { 23 | const state = Map({ 24 | entries: List.of('Trainspotting', '28 Days Later', 'Sunshine') 25 | }); 26 | const nextState = next(state); 27 | expect(nextState).to.equal(Map({ 28 | vote: Map({ 29 | pair: List.of('Trainspotting', '28 Days Later') 30 | }), 31 | entries: List.of('Sunshine') 32 | })); 33 | }); 34 | 35 | it('makes winner when just one entry left', () => { 36 | const state = Map ({ 37 | vote: Map({ 38 | pair: List.of('Trainspotting', '28 Days Later'), 39 | tally: Map({ 40 | 'Trainspotting': 4, 41 | '28 Days Later': 2 42 | }) 43 | }), 44 | entries: List() 45 | }); 46 | const nextState = next(state); 47 | expect(nextState).to.equal(Map({ 48 | winner: 'Trainspotting' 49 | })); 50 | }); 51 | 52 | it('puts winner of current vote back to entries', () => { 53 | const state = Map({ 54 | vote: Map({ 55 | pair: List.of('Trainspotting', '28 Days Later'), 56 | tally: Map({ 57 | 'Trainspotting': 4, 58 | '28 Days Later': 2 59 | }) 60 | }), 61 | entries: List.of('Sunshine', 'Millions', '127 Hours') 62 | }); 63 | const nextState = next(state); 64 | expect(nextState).to.equal(Map({ 65 | vote: Map({ 66 | pair: List.of('Sunshine', 'Millions') 67 | }), 68 | entries: List.of('127 Hours', 'Trainspotting') 69 | })); 70 | }); 71 | 72 | it('puts both from tied vote back to entries', () => { 73 | const state = Map({ 74 | vote: Map({ 75 | pair: List.of('Trainspotting', '28 Days Later'), 76 | tally: Map({ 77 | 'Trainspotting': 3, 78 | '28 Days Later': 3 79 | }) 80 | }), 81 | entries: List.of('Sunshine', 'Millions', '127 Hours') 82 | }); 83 | const nextState = next(state); 84 | expect(nextState).to.equal(Map({ 85 | vote: Map({ 86 | pair: List.of('Sunshine', 'Millions') 87 | }), 88 | entries: List.of('127 Hours', 'Trainspotting', '28 Days Later') 89 | })); 90 | }); 91 | 92 | }); 93 | 94 | describe('vote', () => { 95 | 96 | it('creates a tally for the voted entry', () => { 97 | const state = Map({ 98 | pair: List.of('Trainspotting', '28 Days Later') 99 | }); 100 | const nextState = vote(state, 'Trainspotting') 101 | expect(nextState).to.equal(Map({ 102 | pair: List.of('Trainspotting', '28 Days Later'), 103 | tally: Map({ 104 | 'Trainspotting': 1 105 | }) 106 | })); 107 | }); 108 | 109 | it('adds to existing tally for the voted entry', () => { 110 | const state = Map({ 111 | pair: List.of('Trainspotting', '28 Days Later'), 112 | tally: Map({ 113 | 'Trainspotting': 3, 114 | '28 Days Later': 2 115 | }) 116 | }); 117 | const nextState = vote(state, 'Trainspotting'); 118 | expect(nextState).to.equal(Map({ 119 | pair: List.of('Trainspotting', '28 Days Later'), 120 | tally: Map({ 121 | 'Trainspotting': 4, 122 | '28 Days Later': 2 123 | }) 124 | })); 125 | }); 126 | 127 | }); 128 | 129 | }); 130 | 131 | --------------------------------------------------------------------------------