From a2c108af8055eac4ed8454c28690f65f83bd2e7d Mon Sep 17 00:00:00 2001 From: "Timothy J. Warren" Date: Wed, 3 Aug 2016 20:38:01 -0400 Subject: [PATCH] Add react/ frontend tutorials --- .gitignore | 6 +- .../voting-client/package.json | 50 +++++++ .../voting-client/src/action_creators.js | 21 +++ .../voting-client/src/components/App.jsx | 7 + .../voting-client/src/components/Results.jsx | 62 ++++++++ .../voting-client/src/components/Vote.jsx | 30 ++++ .../voting-client/src/components/Voting.jsx | 28 ++++ .../voting-client/src/components/Winner.jsx | 11 ++ .../voting-client/src/index.jsx | 34 +++++ .../voting-client/src/reducer.js | 42 ++++++ .../src/remote_action_middleware.js | 6 + .../voting-client/src/style.css | 123 ++++++++++++++++ .../test/components/Results_spec.jsx | 54 +++++++ .../test/components/Voting_spec.jsx | 112 +++++++++++++++ .../voting-client/test/reducer_spec.js | 133 ++++++++++++++++++ .../voting-client/test/test_helper.js | 21 +++ .../voting-client/webpack.config.js | 39 +++++ .../voting-server/entries.json | 13 ++ .../voting-server/index.js | 23 +++ .../voting-server/package.json | 32 +++++ .../voting-server/src/core.js | 46 ++++++ .../voting-server/src/reducer.js | 19 +++ .../voting-server/src/server.js | 20 +++ .../voting-server/src/store.js | 6 + .../voting-server/test/core_spec.js | 133 ++++++++++++++++++ .../voting-server/test/immutable_spec.js | 66 +++++++++ .../voting-server/test/reducer_spec.js | 77 ++++++++++ .../voting-server/test/store_spec.js | 21 +++ .../voting-server/test/test_helper.js | 4 + frontendJS/respotify/.babelrc | 3 + frontendJS/respotify/package.json | 28 ++++ frontendJS/respotify/src/greeting.js | 11 ++ frontendJS/respotify/src/index.html | 12 ++ frontendJS/respotify/src/index.js | 8 ++ frontendJS/respotify/webpack.config.js | 36 +++++ 35 files changed, 1336 insertions(+), 1 deletion(-) create mode 100644 frontendJS/full-stack-react-redux/voting-client/package.json create mode 100644 frontendJS/full-stack-react-redux/voting-client/src/action_creators.js create mode 100644 frontendJS/full-stack-react-redux/voting-client/src/components/App.jsx create mode 100644 frontendJS/full-stack-react-redux/voting-client/src/components/Results.jsx create mode 100644 frontendJS/full-stack-react-redux/voting-client/src/components/Vote.jsx create mode 100644 frontendJS/full-stack-react-redux/voting-client/src/components/Voting.jsx create mode 100644 frontendJS/full-stack-react-redux/voting-client/src/components/Winner.jsx create mode 100644 frontendJS/full-stack-react-redux/voting-client/src/index.jsx create mode 100644 frontendJS/full-stack-react-redux/voting-client/src/reducer.js create mode 100644 frontendJS/full-stack-react-redux/voting-client/src/remote_action_middleware.js create mode 100644 frontendJS/full-stack-react-redux/voting-client/src/style.css create mode 100644 frontendJS/full-stack-react-redux/voting-client/test/components/Results_spec.jsx create mode 100644 frontendJS/full-stack-react-redux/voting-client/test/components/Voting_spec.jsx create mode 100644 frontendJS/full-stack-react-redux/voting-client/test/reducer_spec.js create mode 100644 frontendJS/full-stack-react-redux/voting-client/test/test_helper.js create mode 100644 frontendJS/full-stack-react-redux/voting-client/webpack.config.js create mode 100644 frontendJS/full-stack-react-redux/voting-server/entries.json create mode 100644 frontendJS/full-stack-react-redux/voting-server/index.js create mode 100644 frontendJS/full-stack-react-redux/voting-server/package.json create mode 100644 frontendJS/full-stack-react-redux/voting-server/src/core.js create mode 100644 frontendJS/full-stack-react-redux/voting-server/src/reducer.js create mode 100644 frontendJS/full-stack-react-redux/voting-server/src/server.js create mode 100644 frontendJS/full-stack-react-redux/voting-server/src/store.js create mode 100644 frontendJS/full-stack-react-redux/voting-server/test/core_spec.js create mode 100644 frontendJS/full-stack-react-redux/voting-server/test/immutable_spec.js create mode 100644 frontendJS/full-stack-react-redux/voting-server/test/reducer_spec.js create mode 100644 frontendJS/full-stack-react-redux/voting-server/test/store_spec.js create mode 100644 frontendJS/full-stack-react-redux/voting-server/test/test_helper.js create mode 100644 frontendJS/respotify/.babelrc create mode 100644 frontendJS/respotify/package.json create mode 100644 frontendJS/respotify/src/greeting.js create mode 100644 frontendJS/respotify/src/index.html create mode 100644 frontendJS/respotify/src/index.js create mode 100644 frontendJS/respotify/webpack.config.js diff --git a/.gitignore b/.gitignore index b645b87..ef31ca0 100644 --- a/.gitignore +++ b/.gitignore @@ -677,4 +677,8 @@ fabric.properties *.ear # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml -hs_err_pid* \ No newline at end of file +hs_err_pid* + +# Ignore all the stupid idea garbage +**/.idea/**/*.xml +**/.idea/**/*.iml \ No newline at end of file diff --git a/frontendJS/full-stack-react-redux/voting-client/package.json b/frontendJS/full-stack-react-redux/voting-client/package.json new file mode 100644 index 0000000..2e9524a --- /dev/null +++ b/frontendJS/full-stack-react-redux/voting-client/package.json @@ -0,0 +1,50 @@ +{ + "name": "voting-client", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "start": "webpack-dev-server --history-api-fallback", + "test": "mocha --compilers js:babel-core/register --require ./test/test_helper.js \"test/**/*@(.js|.jsx)\"", + "test:watch": "npm run test -- --watch" + }, + "keywords": [], + "author": "", + "license": "ISC", + "devDependencies": { + "autoprefixer": "^6.3.6", + "autoprefixer-loader": "^3.2.0", + "babel-core": "^6.9.1", + "babel-loader": "^6.2.4", + "babel-preset-es2015": "^6.9.0", + "babel-preset-react": "^6.5.0", + "chai": "^3.5.0", + "chai-immutable": "^1.6.0", + "css-loader": "^0.23.1", + "jsdom": "^9.2.1", + "mocha": "^2.5.3", + "postcss-loader": "^0.9.1", + "react-hot-loader": "^1.3.0", + "style-loader": "^0.13.1", + "webpack": "^1.13.1", + "webpack-dev-server": "^1.14.1" + }, + "babel": { + "presets": [ + "es2015", + "react" + ] + }, + "dependencies": { + "classnames": "^2.2.5", + "immutable": "^3.8.1", + "react": "^15.1.0", + "react-addons-pure-render-mixin": "^15.1.0", + "react-addons-test-utils": "^15.1.0", + "react-dom": "^15.1.0", + "react-redux": "^4.4.5", + "react-router": "^2.4.1", + "redux": "^3.5.2", + "socket.io-client": "^1.4.6" + } +} diff --git a/frontendJS/full-stack-react-redux/voting-client/src/action_creators.js b/frontendJS/full-stack-react-redux/voting-client/src/action_creators.js new file mode 100644 index 0000000..5af2277 --- /dev/null +++ b/frontendJS/full-stack-react-redux/voting-client/src/action_creators.js @@ -0,0 +1,21 @@ +export function setState(state) { + return { + type: 'SET_STATE', + state + }; +} + +export function vote(entry) { + return { + meta: {remote: true}, + type: 'VOTE', + entry + }; +} + +export function next() { + return { + meta: {remote: true}, + type: 'NEXT' + }; +} \ No newline at end of file diff --git a/frontendJS/full-stack-react-redux/voting-client/src/components/App.jsx b/frontendJS/full-stack-react-redux/voting-client/src/components/App.jsx new file mode 100644 index 0000000..792bc92 --- /dev/null +++ b/frontendJS/full-stack-react-redux/voting-client/src/components/App.jsx @@ -0,0 +1,7 @@ +import React from 'react'; + +export default React.createClass({ + render: function() { + return this.props.children; + } +}); \ No newline at end of file diff --git a/frontendJS/full-stack-react-redux/voting-client/src/components/Results.jsx b/frontendJS/full-stack-react-redux/voting-client/src/components/Results.jsx new file mode 100644 index 0000000..ace9bdc --- /dev/null +++ b/frontendJS/full-stack-react-redux/voting-client/src/components/Results.jsx @@ -0,0 +1,62 @@ +import React from 'react'; +import PureRenderMixin from 'react-addons-pure-render-mixin'; +import {connect} from 'react-redux'; +import Winner from './Winner'; +import * as actionCreators from '../action_creators'; + +export const VOTE_WIDTH_PERCENT = 8; + +export const Results = React.createClass({ + mixins: [PureRenderMixin], + getPair: function () { + return this.props.pair || []; + }, + getVotes: function(entry) { + if (this.props.tally && this.props.tally.has(entry)) { + return this.props.tally.get(entry); + } + + return 0; + }, + getVotesBlockWidth: function(entry) { + console.log(entry); + console.log(this.getVotes(entry)); + return (this.getVotes(entry) * VOTE_WIDTH_PERCENT) + '%'; + }, + render: function () { + return this.props.winner ? + : +
+
+ {this.getPair().map(entry => +
+

{entry}

+
+
+
+
+
+ {this.getVotes(entry)} +
+
+ )} +
+
+ +
+
; + } +}); + +function mapStateToProps(state) { + return { + pair: state.getIn(['vote', 'pair']), + tally: state.getIn(['vote', 'tally']), + winner: state.get('winner') + } +} + +export const ResultsContainer = connect(mapStateToProps, actionCreators)(Results); \ No newline at end of file diff --git a/frontendJS/full-stack-react-redux/voting-client/src/components/Vote.jsx b/frontendJS/full-stack-react-redux/voting-client/src/components/Vote.jsx new file mode 100644 index 0000000..0c97d0a --- /dev/null +++ b/frontendJS/full-stack-react-redux/voting-client/src/components/Vote.jsx @@ -0,0 +1,30 @@ +import React from 'react'; +import PureRenderMixin from 'react-addons-pure-render-mixin'; +import classNames from 'classnames'; + +export default React.createClass({ + mixins: [PureRenderMixin], + getPair: function() { + return this.props.pair || []; + }, + isDisabled: function() { + return !! this.props.hasVoted; + }, + hasVotedFor: function(entry) { + return this.props.hasVoted === entry; + }, + render: function() { + return
+ {this.getPair().map(entry => + + )} +
; + } +}); \ No newline at end of file diff --git a/frontendJS/full-stack-react-redux/voting-client/src/components/Voting.jsx b/frontendJS/full-stack-react-redux/voting-client/src/components/Voting.jsx new file mode 100644 index 0000000..150b1bd --- /dev/null +++ b/frontendJS/full-stack-react-redux/voting-client/src/components/Voting.jsx @@ -0,0 +1,28 @@ +import React from 'react'; +import PureRenderMixin from 'react-addons-pure-render-mixin'; +import {connect} from 'react-redux'; +import Winner from './Winner'; +import Vote from './Vote'; +import * as actionCreators from '../action_creators'; + +export const Voting = React.createClass({ + mixins: [PureRenderMixin], + render: function() { + return
+ {this.props.winner + ? + : } + +
; + } +}); + +function mapStateToProps(state) { + return { + pair: state.getIn(['vote', 'pair']), + hasVoted: state.get('hasVoted'), + winner: state.get('winner') + }; +} + +export const VotingContainer = connect(mapStateToProps, actionCreators)(Voting); diff --git a/frontendJS/full-stack-react-redux/voting-client/src/components/Winner.jsx b/frontendJS/full-stack-react-redux/voting-client/src/components/Winner.jsx new file mode 100644 index 0000000..2fe1270 --- /dev/null +++ b/frontendJS/full-stack-react-redux/voting-client/src/components/Winner.jsx @@ -0,0 +1,11 @@ +import React from 'react'; +import PureRenderMixin from 'react-addons-pure-render-mixin'; + +export default React.createClass({ + mixins: [PureRenderMixin], + render: function() { + return
+ Winner is {this.props.winner}! +
; + } +}); \ No newline at end of file diff --git a/frontendJS/full-stack-react-redux/voting-client/src/index.jsx b/frontendJS/full-stack-react-redux/voting-client/src/index.jsx new file mode 100644 index 0000000..67b0fa1 --- /dev/null +++ b/frontendJS/full-stack-react-redux/voting-client/src/index.jsx @@ -0,0 +1,34 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import {Router, Route, browserHistory} from 'react-router'; +import {createStore, applyMiddleware} from 'redux'; +import {Provider} from 'react-redux'; +import io from 'socket.io-client'; +import reducer from './reducer'; +import {setState} from './action_creators'; +import remoteActionMiddleware from './remote_action_middleware'; +import App from './components/App'; +import {VotingContainer} from './components/Voting'; +import {ResultsContainer} from './components/Results'; + +require('./style.css'); + +const socket = io(`${location.protocol}//${location.hostname}:8090`); +socket.on('state', state => store.dispatch(setState(state))); + +const createStoreWidthMiddleware = applyMiddleware( + remoteActionMiddleware(socket) +)(createStore); +const store = createStoreWidthMiddleware(reducer); + +const routes = + + +; + +ReactDOM.render( + + {routes} + , + document.getElementById('app') +); \ No newline at end of file diff --git a/frontendJS/full-stack-react-redux/voting-client/src/reducer.js b/frontendJS/full-stack-react-redux/voting-client/src/reducer.js new file mode 100644 index 0000000..5ad739a --- /dev/null +++ b/frontendJS/full-stack-react-redux/voting-client/src/reducer.js @@ -0,0 +1,42 @@ +import {Map} from 'immutable'; + +const SET_STATE = 'SET_STATE'; +const VOTE = 'VOTE'; + + +function setState(state, newState) { + return state.merge(newState); +} + +function vote(state, entry) { + const currentPair = state.getIn(['vote', 'pair']); + if (currentPair && currentPair.includes(entry)) { + return state.set('hasVoted', entry); + } + + return state; +} + +function resetVote(state) { + const hasVoted = state.get('hasVoted'); + const currentPair = state.getIn(['vote', 'pair']); + if (hasVoted && ! currentPair.includes(hasVoted)) { + return state.remove('hasVoted'); + } + + return state; +} + +export default function(state = Map(), action) { + switch(action.type) { + + case SET_STATE: + return resetVote(setState(state, action.state)); + + case VOTE: + return vote(state, action.entry); + + default: + return state; + } +} \ No newline at end of file diff --git a/frontendJS/full-stack-react-redux/voting-client/src/remote_action_middleware.js b/frontendJS/full-stack-react-redux/voting-client/src/remote_action_middleware.js new file mode 100644 index 0000000..6f95dbe --- /dev/null +++ b/frontendJS/full-stack-react-redux/voting-client/src/remote_action_middleware.js @@ -0,0 +1,6 @@ +export default socket => store => next => action => { + if (action.meta && action.meta.remote) { + socket.emit('action', action); + } + return next(action); +}; \ No newline at end of file diff --git a/frontendJS/full-stack-react-redux/voting-client/src/style.css b/frontendJS/full-stack-react-redux/voting-client/src/style.css new file mode 100644 index 0000000..3e1d12e --- /dev/null +++ b/frontendJS/full-stack-react-redux/voting-client/src/style.css @@ -0,0 +1,123 @@ +body { + font-family: 'Open Sans', sans-serif; + background-color: #673AB7; + color: white; +} + +/* Voting Screen */ + +.voting { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + + display: flex; + flex-direction: column; + + user-select: none; +} + +.voting button { + flex: 1 0 0; + + background-color: #673AB7; + border-width: 0; +} +.voting button:first-child { + border-bottom: 1px solid white; +} +.voting button:active { + background-color: white; + color: #311B92; +} +.voting button.voted { + background-color: #311B92; +} +.voting button:not(.voted) .label { + visibility: hidden; +} +.voting button .label { + opacity: 0.87; +} +.voting button.votedAgainst * { + opacity: 0.3; +} + +@media only screen and (min-device-width: 500px) { + .voting { + flex-direction: row; + } + .voting button:first-child { + border-bottom-width: 0; + border-right: 1px solid white; + } +} + +/* Results Screen */ + +.results { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + + display: flex; + flex-direction: column; +} +.results .tally { + flex: 1; + + display: flex; + flex-direction: column; + justify-content: center; +} +.results .tally .entry { + display: flex; + justify-content: space-around; + align-items: center; +} + +.results .tally h1 { + width: 25%; +} +.results .tally .voteVisualization { + height: 50px; + width: 50%; + display: flex; + justify-content: flex-start; + + background-color: #7E57C2; +} +.results .tally .votesBlock { + background-color: white; + transition: width 0.5s; +} +.results .tally .voteCount { + font-size: 2rem; +} + +.results .management { + display: flex; + + height: 2em; + border-top: 1px solid #aaa; +} + +.results .management button { + border: 0; + background-color: black; + color: #aaa; +} +.results .management .next { + flex: 1; +} + +/* Winner View */ + +.winner { + font-size: 4rem; + text-align: center; +} diff --git a/frontendJS/full-stack-react-redux/voting-client/test/components/Results_spec.jsx b/frontendJS/full-stack-react-redux/voting-client/test/components/Results_spec.jsx new file mode 100644 index 0000000..a52dc7a --- /dev/null +++ b/frontendJS/full-stack-react-redux/voting-client/test/components/Results_spec.jsx @@ -0,0 +1,54 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import { + renderIntoDocument, + scryRenderedDOMComponentsWithClass, + Simulate +} from 'react-addons-test-utils'; +import {List, Map} from 'immutable'; +import {Results} from '../../src/components/Results'; +import {expect} from 'chai'; + +describe('Results', () => { + + it('renders entries with vote counts or zero', () => { + const pair = List.of('Trainspotting', '28 Days Later'); + const tally = Map({'Trainspotting': 5}); + const component = renderIntoDocument( + + ); + const entries = scryRenderedDOMComponentsWithClass(component, 'entry'); + const [train, days] = entries.map(e => e.textContent); + + expect(entries.length).to.equal(2); + expect(train).to.contain('Trainspotting'); + expect(train).to.contain('5'); + expect(days).to.contain('28 Days Later'); + expect(days).to.contain('0'); + }); + + it('invokes the next callback when next button is clicked', () => { + let nextInvoked = false; + const next = () => nextInvoked = true; + + const pair = List.of('Trainspotting', '28 Days Later'); + const component = renderIntoDocument( + + ); + + Simulate.click(ReactDOM.findDOMNode(component.refs.next)); + + expect(nextInvoked).to.equal(true); + }); + + it('renders the winner when there is one', () => { + const conponent = renderIntoDocument( + + ); + const winner = ReactDOM.findDOMNode(conponent.refs.winner); + expect(winner).to.be.ok; + expect(winner.textContent).to.contain('Trainspotting'); + }); +}); \ No newline at end of file diff --git a/frontendJS/full-stack-react-redux/voting-client/test/components/Voting_spec.jsx b/frontendJS/full-stack-react-redux/voting-client/test/components/Voting_spec.jsx new file mode 100644 index 0000000..c9bcd52 --- /dev/null +++ b/frontendJS/full-stack-react-redux/voting-client/test/components/Voting_spec.jsx @@ -0,0 +1,112 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import { + renderIntoDocument, + scryRenderedDOMComponentsWithTag, + Simulate +} from 'react-addons-test-utils'; + +import {List} from 'immutable'; +import {Voting} from '../../src/components/Voting'; +import {expect} from 'chai'; + +describe('Voting', () => { + + it('renders a pair of buttons', () => { + const component = renderIntoDocument( + + ); + const buttons = scryRenderedDOMComponentsWithTag(component, 'button'); + + expect(buttons.length).to.equal(2); + expect(buttons[0].textContent).to.equal('Trainspotting'); + expect(buttons[1].textContent).to.equal('28 Days Later'); + }); + + it('invokes callback when a button is clicked', () => { + let votedWith; + const vote = (entry) => votedWith = entry; + + const component = renderIntoDocument( + + ); + const buttons = scryRenderedDOMComponentsWithTag(component, 'button'); + Simulate.click(buttons[0]); + + expect(votedWith).to.equal('Trainspotting'); + }); + + it('disables buttons when user has voted', () => { + const component = renderIntoDocument( + + ); + const buttons = scryRenderedDOMComponentsWithTag(component, 'button'); + + expect(buttons.length).to.equal(2); + expect(buttons[0].hasAttribute('disabled')).to.equal(true); + expect(buttons[1].hasAttribute('disabled')).to.equal(true); + }); + + it('adds label to the voted entry', () => { + const component = renderIntoDocument( + + ); + const buttons = scryRenderedDOMComponentsWithTag(component, 'button'); + + expect(buttons[0].textContent).to.contain('Voted'); + }); + + it('renders just the winner when there is one', () => { + const component = renderIntoDocument( + + ); + const buttons = scryRenderedDOMComponentsWithTag(component, 'button'); + expect(buttons.length).to.equal(0); + + const winner = ReactDOM.findDOMNode(component.refs.winner); + expect(winner).to.be.ok; + expect(winner.textContent).to.contain('Trainspotting'); + }); + + it('renders as a pure component', () => { + const pair = ['Trainspotting', '28 Days Later']; + const container = document.createElement('div'); + let component = ReactDOM.render( + , + container + ); + + let firstButton = scryRenderedDOMComponentsWithTag(component, 'button')[0]; + expect(firstButton.textContent).to.equal('Trainspotting'); + + pair[0] = 'Sunshine'; + component = ReactDOM.render( + , + container + ); + firstButton = scryRenderedDOMComponentsWithTag(component, 'button')[0]; + expect(firstButton.textContent).to.equal('Trainspotting'); + }); + + it('does update DOM when prop changes', () => { + const pair = List.of('Trainspotting', '28 Days Later'); + const container = document.createElement('div'); + let component = ReactDOM.render( + , + container + ); + + let firstButton = scryRenderedDOMComponentsWithTag(component, 'button')[0]; + expect(firstButton.textContent).to.equal('Trainspotting'); + + const newPair = pair.set(0, 'Sunshine'); + component = ReactDOM.render( + , + container + ); + firstButton = scryRenderedDOMComponentsWithTag(component, 'button')[0]; + expect(firstButton.textContent).to.equal('Sunshine'); + }); +}); \ No newline at end of file diff --git a/frontendJS/full-stack-react-redux/voting-client/test/reducer_spec.js b/frontendJS/full-stack-react-redux/voting-client/test/reducer_spec.js new file mode 100644 index 0000000..0a118e7 --- /dev/null +++ b/frontendJS/full-stack-react-redux/voting-client/test/reducer_spec.js @@ -0,0 +1,133 @@ +import {List, Map, fromJS} from 'immutable'; +import {expect} from 'chai'; + +import reducer from '../src/reducer'; + +describe('reducer', () => { + + it('handles SET_STATE', () => { + const initialState = Map(); + const action = { + type: 'SET_STATE', + state: Map({ + vote: Map({ + pair: List.of('Trainspotting', '28 Days Later'), + tally: Map({ + Trainspotting: 1 + }) + }) + }) + }; + const nextState = reducer(initialState, action); + + expect(nextState).to.equal(fromJS({ + vote: { + pair: ['Trainspotting', '28 Days Later'], + tally: {Trainspotting: 1} + } + })); + }); + + it('handles SET_STATE with plain JS payload', () => { + const initialState = Map(); + const action = { + type: 'SET_STATE', + state: { + vote: { + pair: ['Trainspotting', '28 Days Later'], + tally: {Trainspotting: 1} + } + } + }; + const nextState = reducer(initialState, action); + + expect(nextState).to.equal(fromJS({ + vote: { + pair: ['Trainspotting', '28 Days Later'], + tally: {Trainspotting: 1} + } + })); + }); + + it('handles SET_STATE without initial state', () => { + const action = { + type: 'SET_STATE', + state: { + vote: { + pair: ['Trainspotting', '28 Days Later'], + tally: {Trainspotting: 1} + } + } + }; + const nextState = reducer(undefined, action); + + expect(nextState).to.equal(fromJS({ + vote: { + pair: ['Trainspotting', '28 Days Later'], + tally: {Trainspotting: 1} + } + })); + }); + + it('handles VOTE by setting hasVoted', () => { + const state = fromJS({ + vote: { + pair: ['Trainspotting', '28 Days Later'], + tally: {Trainspotting: 1} + } + }); + const action = {type: 'VOTE', entry: 'Trainspotting'}; + const nextState = reducer(state, action); + + expect(nextState).to.equal(fromJS({ + vote: { + pair: ['Trainspotting', '28 Days Later'], + tally: {Trainspotting: 1} + }, + hasVoted: 'Trainspotting' + })); + }); + + it('does not set hasVoted for VOTE on invalid entry', () => { + const state = fromJS({ + vote: { + pair: ['Trainspotting', '28 Days Later'], + tally: {Trainspotting: 1} + } + }); + const action = {type: 'VOTE', entry: 'Sunshine'}; + const nextState = reducer(state, action); + + expect(nextState).to.equal(fromJS({ + vote: { + pair: ['Trainspotting', '28 Days Later'], + tally: {Trainspotting: 1} + } + })); + }); + + it('removes hasVoted on SET_STATE if pair changes', () => { + const initialState = fromJS({ + vote: { + pair: ['Trainspotting', '28 Days Later'], + tally: {Trainspotting: 1} + }, + hasVoted: 'Trainspotting' + }); + const action = { + type: 'SET_STATE', + state: { + vote: { + pair: ['Sunshine', 'Slumdog Millionaire'] + } + } + }; + const nextState = reducer(initialState, action); + + expect(nextState).to.equal(fromJS({ + vote: { + pair: ['Sunshine', 'Slumdog Millionaire'] + } + })); + }); +}); \ No newline at end of file diff --git a/frontendJS/full-stack-react-redux/voting-client/test/test_helper.js b/frontendJS/full-stack-react-redux/voting-client/test/test_helper.js new file mode 100644 index 0000000..824d268 --- /dev/null +++ b/frontendJS/full-stack-react-redux/voting-client/test/test_helper.js @@ -0,0 +1,21 @@ +/** + * Fakes a browser environment for testing react + */ +import jsdom from 'jsdom'; +import chai from 'chai'; +import chaiImmutable from 'chai-immutable'; + +const doc = jsdom.jsdom(''); +const win = doc.defaultView; + +global.document = doc; +global.window = win; + +// Hoist window properties to global +Object.keys(window).forEach(key => { + if ( ! (key in global)) { + global[key] = window[key]; + } +}); + +chai.use(chaiImmutable); \ No newline at end of file diff --git a/frontendJS/full-stack-react-redux/voting-client/webpack.config.js b/frontendJS/full-stack-react-redux/voting-client/webpack.config.js new file mode 100644 index 0000000..c9e4e87 --- /dev/null +++ b/frontendJS/full-stack-react-redux/voting-client/webpack.config.js @@ -0,0 +1,39 @@ +const autoprefixer = require('autoprefixer'); +const webpack = require('webpack'); + +module.exports = { + entry: [ + 'webpack-dev-server/client?http://localhost:8080', + 'webpack/hot/only-dev-server', + './src/index.jsx' + ], + module: { + loaders: [{ + text: /\.jsx?$/, + exclude: /node_modules/, + loader: 'react-hot!babel' + }, { + test: /\.css$/, + loader: 'style-loader!css-loader!postcss-loader' + }] + }, + resolve: { + extensions: ['', '.js', '.jsx'] + }, + output: { + path: `${__dirname}/dist`, + publicPath: '/', + filename: 'bundle.js' + }, + devServer: { + contentBase: './dist', + hot: true + }, + plugins: [ + new webpack.HotModuleReplacementPlugin() + ], + postcss: () => { + return [autoprefixer]; + }, + devtool: '#source-map' +}; \ No newline at end of file diff --git a/frontendJS/full-stack-react-redux/voting-server/entries.json b/frontendJS/full-stack-react-redux/voting-server/entries.json new file mode 100644 index 0000000..1899351 --- /dev/null +++ b/frontendJS/full-stack-react-redux/voting-server/entries.json @@ -0,0 +1,13 @@ +[ + "Shallow Grave", + "Trainspotting", + "A Life Less Ordinary", + "The Beach", + "28 Days Later", + "Millions", + "Sunshine", + "Slumdog Millionaire", + "127 Hours", + "Trance", + "Steve Jobs" +] \ No newline at end of file diff --git a/frontendJS/full-stack-react-redux/voting-server/index.js b/frontendJS/full-stack-react-redux/voting-server/index.js new file mode 100644 index 0000000..6ab892b --- /dev/null +++ b/frontendJS/full-stack-react-redux/voting-server/index.js @@ -0,0 +1,23 @@ +// Overall summary of architecture: +// 1. A client sends an action to the server +// 2. The server hands the action to the Redux store +// 3. The store calls the reducer and the reducer executes the logic related to +// the action +// 4. The store updates its state based on the return value of the reducer +// 5. The store executes its listener function subscribed by the server +// 6. The server emits a 'state' event +// 7. All connected clients -- including the on that initiated teh original +// action -- receive the new state +import makeStore from './src/store'; +import startServer from './src/server'; + +export const store = makeStore(); +startServer(store); + +// Preload the state +store.dispatch({ + type: 'SET_ENTRIES', + entries: require('./entries.json') +}); +store.dispatch({type: 'NEXT'}); + diff --git a/frontendJS/full-stack-react-redux/voting-server/package.json b/frontendJS/full-stack-react-redux/voting-server/package.json new file mode 100644 index 0000000..8b60f5d --- /dev/null +++ b/frontendJS/full-stack-react-redux/voting-server/package.json @@ -0,0 +1,32 @@ +{ + "name": "voting-server", + "version": "1.0.0", + "description": "", + "main": "index.js", + "babel": { + "presets": [ + "es2015" + ] + }, + "scripts": { + "start": "babel-node index.js", + "test": "mocha --compilers js:babel-core/register --require ./test/test_helper.js --recursive", + "test:watch": "npm run test -- --watch" + }, + "keywords": [], + "author": "", + "license": "ISC", + "devDependencies": { + "babel-cli": "^6.10.1", + "babel-core": "^6.9.1", + "babel-preset-es2015": "^6.9.0", + "chai": "^3.5.0", + "chai-immutable": "^1.6.0", + "mocha": "^2.5.3" + }, + "dependencies": { + "immutable": "^3.8.1", + "redux": "^3.5.2", + "socket.io": "^1.4.6" + } +} diff --git a/frontendJS/full-stack-react-redux/voting-server/src/core.js b/frontendJS/full-stack-react-redux/voting-server/src/core.js new file mode 100644 index 0000000..eabe143 --- /dev/null +++ b/frontendJS/full-stack-react-redux/voting-server/src/core.js @@ -0,0 +1,46 @@ +import {List, Map} from 'immutable'; + +export const INITIAL_STATE = Map(); + +export function setEntries(state, entries) { + return state.set('entries', List(entries)); +} + +function getWinners(vote) { + if ( ! vote) return []; + const [a, b] = vote.get('pair'); + const aVotes = vote.getIn(['tally', a], 0); + const bVotes = vote.getIn(['tally', b], 0); + + if (aVotes > bVotes) return [a]; + else if (aVotes < bVotes) return [b]; + else { + return [a, b] + } +} + +export function next(state) { + const entries = state.get('entries') + .concat(getWinners(state.get('vote'))); + + if (entries.size === 1) { + return state.remove('vote') + .remove('entries') + .set('winner', entries.first()); + } else { + return state.merge({ + vote: Map({ + pair: entries.take(2) + }), + entries: entries.skip(2) + }); + } +} + +export function vote(voteState, entry) { + return voteState.updateIn( + ['tally', entry], + 0, + tally => tally + 1 + ); +} \ No newline at end of file diff --git a/frontendJS/full-stack-react-redux/voting-server/src/reducer.js b/frontendJS/full-stack-react-redux/voting-server/src/reducer.js new file mode 100644 index 0000000..c348f00 --- /dev/null +++ b/frontendJS/full-stack-react-redux/voting-server/src/reducer.js @@ -0,0 +1,19 @@ +import {setEntries, next, vote, INITIAL_STATE} from './core'; + +export default function reducer(state = INITIAL_STATE, action) { + // Figure out which function to call and call it + switch(action.type) { + case 'SET_ENTRIES': + return setEntries(state, action.entries); + + case 'NEXT': + return next(state); + + case 'VOTE': + return state.update('vote', + voteState => vote(voteState, action.entry)); + + default: + return state; + } +} \ No newline at end of file diff --git a/frontendJS/full-stack-react-redux/voting-server/src/server.js b/frontendJS/full-stack-react-redux/voting-server/src/server.js new file mode 100644 index 0000000..cd0bc39 --- /dev/null +++ b/frontendJS/full-stack-react-redux/voting-server/src/server.js @@ -0,0 +1,20 @@ +import Server from 'socket.io'; + +export default function startServer(store) { + const io = new Server().attach(8090); + + store.subscribe( + // Send 'state' events when updating state via Redux + () => io.emit('state', store.getState().toJS()) + ); + + io.on('connection', socket => { + // Send state on connection + socket.emit('state', store.getState().toJS()); + + // Subscribe to action events sent by clients + // NOTE: this is dangerously insecure -- any client can trigger + // any action + socket.on('action', store.dispatch.bind(store)); + }); +} \ No newline at end of file diff --git a/frontendJS/full-stack-react-redux/voting-server/src/store.js b/frontendJS/full-stack-react-redux/voting-server/src/store.js new file mode 100644 index 0000000..f8be799 --- /dev/null +++ b/frontendJS/full-stack-react-redux/voting-server/src/store.js @@ -0,0 +1,6 @@ +import {createStore} from 'redux'; +import reducer from './reducer'; + +export default function makeStore() { + return createStore(reducer); +} \ No newline at end of file diff --git a/frontendJS/full-stack-react-redux/voting-server/test/core_spec.js b/frontendJS/full-stack-react-redux/voting-server/test/core_spec.js new file mode 100644 index 0000000..4e9983c --- /dev/null +++ b/frontendJS/full-stack-react-redux/voting-server/test/core_spec.js @@ -0,0 +1,133 @@ +import {List, Map} from 'immutable'; +import {expect} from 'chai'; +import {setEntries, next, vote} from '../src/core'; + +describe('application logic', () => { + + describe('setEntries', () => { + + it('adds the entries to the state', () => { + const state = Map(); + const entries = List.of('Trainspotting', '28 Days Later'); + const nextState = setEntries(state, entries); + expect(nextState).to.equal(Map({ + entries: List.of('Trainspotting', '28 Days Later') + })); + }); + + it('converts to immutable', () => { + const state = Map(); + const entries = ['Trainspotting', '28 Days Later']; + const nextState = setEntries(state, entries); + expect(nextState).to.equal(Map({ + entries: List.of('Trainspotting', '28 Days Later') + })); + }); + + }); + + describe('next', () => { + it('takes the next two entries under vote', () => { + const state = Map({ + entries: List.of('Trainspotting', '28 Days Later', 'Sunshine') + }); + const nextState = next(state); + expect(nextState).to.equal(Map({ + vote: Map({ + pair: List.of('Trainspotting', '28 Days Later') + }), + entries: List.of('Sunshine') + })); + }); + + it('puts winner of current vote back to entries', () => { + const state = Map({ + vote: Map({ + pair: List.of('Trainspotting', '28 Days Later'), + tally: Map({ + 'Trainspotting': 4, + '28 Days Later': 2 + }) + }), + entries: List.of('Sunshine', 'Millions', '127 Hours') + }); + const nextState = next(state); + expect(nextState).to.equal(Map({ + vote: Map({ + pair: List.of('Sunshine', 'Millions') + }), + entries: List.of('127 Hours', 'Trainspotting') + })); + }); + + it('puts both from tied vote back to entries', () => { + const state = Map({ + vote: Map({ + pair: List.of('Trainspotting', '28 Days Later'), + tally: Map({ + 'Trainspotting': 3, + '28 Days Later': 3 + }) + }), + entries: List.of('Sunshine', 'Millions', '127 Hours') + }); + const nextState = next(state); + expect(nextState).to.equal(Map({ + vote: Map({ + pair: List.of('Sunshine', 'Millions') + }), + entries: List.of('127 Hours', 'Trainspotting', '28 Days Later') + })); + }); + + it('marks winner when just one entry left', () => { + const state = Map({ + vote: Map({ + pair: List.of('Trainspotting', '28 Days Later'), + tally: Map({ + 'Trainspotting': 4, + '28 Days Later': 2 + }) + }), + entries: List() + }); + const nextState = next(state); + expect(nextState).to.equal(Map({ + winner: 'Trainspotting' + })); + }); + }); + + describe('vote', () => { + it('creates a tally for the voted entry', () => { + const state = Map({ + pair: List.of('Trainspotting', '28 Days Later') + }); + const nextState = vote(state, 'Trainspotting'); + expect(nextState).to.equal(Map({ + pair: List.of('Trainspotting', '28 Days Later'), + tally: Map({ + Trainspotting: 1 + }) + })); + }); + + it('adds to existing tally for the voted entry', () => { + const state = Map({ + pair: List.of('Trainspotting', '28 Days Later'), + tally: Map({ + 'Trainspotting': 3, + '28 Days Later': 2 + }) + }); + const nextState = vote(state, 'Trainspotting'); + expect(nextState).to.equal(Map({ + pair: List.of('Trainspotting', '28 Days Later'), + tally: Map({ + 'Trainspotting': 4, + '28 Days Later': 2 + }) + })); + }); + }); +}); \ No newline at end of file diff --git a/frontendJS/full-stack-react-redux/voting-server/test/immutable_spec.js b/frontendJS/full-stack-react-redux/voting-server/test/immutable_spec.js new file mode 100644 index 0000000..51df945 --- /dev/null +++ b/frontendJS/full-stack-react-redux/voting-server/test/immutable_spec.js @@ -0,0 +1,66 @@ +import {expect} from 'chai'; +import {List, Map} from 'immutable'; + +describe('immutability', () => { + describe('a number', () => { + function increment(currentState) { + return currentState + 1; + } + + it('is immutable', () => { + let state = 42; + let nextState = increment(state); + + expect(nextState).to.equal(43); + expect(state).to.equal(42); + }); + }); + + describe('a list', () => { + function addMovie(currentState, movie) { + return currentState.push(movie); + } + + it('is immutable', () => { + let state = List.of('Trainspotting', '28 Days Later'); + let nextState = addMovie(state, 'Sunshine'); + + expect(nextState).to.equal(List.of( + 'Trainspotting', + '28 Days Later', + 'Sunshine' + )); + expect(state).to.equal(List.of( + 'Trainspotting', + '28 Days Later' + )); + }); + }); + + describe('a tree', () => { + function addMovie(currentState, movie) { + return currentState.update('movies', movies => movies.push(movie)); + } + + it('is immutable', () => { + let state = Map({ + movies: List.of('Trainspotting', '28 Days Later') + }); + let nextState = addMovie(state, 'Sunshine'); + + expect(nextState).to.equal(Map({ + movies: List.of( + 'Trainspotting', + '28 Days Later', + 'Sunshine' + ) + })); + expect(state).to.equal(Map({ + movies: List.of( + 'Trainspotting', + '28 Days Later' + ) + })); + }); + }); +}); \ No newline at end of file diff --git a/frontendJS/full-stack-react-redux/voting-server/test/reducer_spec.js b/frontendJS/full-stack-react-redux/voting-server/test/reducer_spec.js new file mode 100644 index 0000000..ee81f0f --- /dev/null +++ b/frontendJS/full-stack-react-redux/voting-server/test/reducer_spec.js @@ -0,0 +1,77 @@ +import {Map, fromJS} from 'immutable'; +import {expect} from 'chai'; + +import reducer from '../src/reducer'; + +describe('reducer', () => { + + it('handles SET_ENTRIES', () => { + const initialState = Map(); + const action = {type: 'SET_ENTRIES', entries: ['Trainspotting']}; + const nextState = reducer(initialState, action); + + expect(nextState).to.equal(fromJS({ + entries: ['Trainspotting'] + })); + }); + + it('handles NEXT', () => { + const initialState = fromJS({ + entries: ['Trainspotting', '28 Days Later'] + }); + const action = {type: 'NEXT'}; + const nextState = reducer(initialState, action); + + expect(nextState).to.equal(fromJS({ + vote: { + pair: ['Trainspotting', '28 Days Later'] + }, + entries: [] + })); + }); + + it('handles VOTE', () => { + const initialState = fromJS({ + vote: { + pair: ['Trainspotting', '28 Days Later'] + }, + entries: [] + }); + const action = {type: 'VOTE', entry: 'Trainspotting'}; + const nextState = reducer(initialState, action); + + expect(nextState).to.equal(fromJS({ + vote: { + pair: ['Trainspotting', '28 Days Later'], + tally: { + Trainspotting: 1 + } + }, + entries: [] + })); + }); + + it('has an initial state', () => { + const action = {type: 'SET_ENTRIES', entries: ['Trainspotting']}; + const nextState = reducer(undefined, action); + expect(nextState).to.equal(fromJS({ + entries: ['Trainspotting'] + })); + }); + + it('can be used with reduce', () => { + const actions = [ + {type: 'SET_ENTRIES', entries: ['Trainspotting', '28 Days Later']}, + {type: 'NEXT'}, + {type: 'VOTE', entry: 'Trainspotting'}, + {type: 'VOTE', entry: '28 Days Later'}, + {type: 'VOTE', entry: 'Trainspotting'}, + {type: 'NEXT'} + ]; + const finalState = actions.reduce(reducer, Map()); + + expect(finalState).to.equal(fromJS({ + winner: 'Trainspotting' + })); + }); +}); \ No newline at end of file diff --git a/frontendJS/full-stack-react-redux/voting-server/test/store_spec.js b/frontendJS/full-stack-react-redux/voting-server/test/store_spec.js new file mode 100644 index 0000000..b1bc97d --- /dev/null +++ b/frontendJS/full-stack-react-redux/voting-server/test/store_spec.js @@ -0,0 +1,21 @@ +import {Map, fromJS} from 'immutable'; +import {expect} from 'chai'; + +import makeStore from '../src/store'; + +describe('store', () => { + + it('is a Redux store configured with the correct reducer', () => { + const store = makeStore(); + expect(store.getState()).to.equal(Map()); + + store.dispatch({ + type: 'SET_ENTRIES', + entries: ['Trainspotting', '28 Days Later'] + }); + expect(store.getState()).to.equal(fromJS({ + entries: ['Trainspotting', '28 Days Later'] + })); + }); + +}); \ No newline at end of file diff --git a/frontendJS/full-stack-react-redux/voting-server/test/test_helper.js b/frontendJS/full-stack-react-redux/voting-server/test/test_helper.js new file mode 100644 index 0000000..a87528d --- /dev/null +++ b/frontendJS/full-stack-react-redux/voting-server/test/test_helper.js @@ -0,0 +1,4 @@ +import chai from 'chai'; +import chaiImmutable from 'chai-immutable'; + +chai.use(chaiImmutable); \ No newline at end of file diff --git a/frontendJS/respotify/.babelrc b/frontendJS/respotify/.babelrc new file mode 100644 index 0000000..8d73672 --- /dev/null +++ b/frontendJS/respotify/.babelrc @@ -0,0 +1,3 @@ +{ + "presets": ["react", "es2015"] +} \ No newline at end of file diff --git a/frontendJS/respotify/package.json b/frontendJS/respotify/package.json new file mode 100644 index 0000000..a092c5b --- /dev/null +++ b/frontendJS/respotify/package.json @@ -0,0 +1,28 @@ +{ + "name": "respotify", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "start": "webpack-dev-server --hot --inline", + "build": "webpack", + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "devDependencies": { + "babel-core": "^6.10.4", + "babel-loader": "^6.2.4", + "babel-preset-es2015": "^6.9.0", + "babel-preset-react": "^6.11.1", + "file-loader": "^0.9.0", + "react-hot-loader": "^1.3.0", + "webpack": "^1.13.1", + "webpack-dev-server": "^1.14.1" + }, + "dependencies": { + "react": "^15.2.0", + "react-dom": "^15.2.0" + } +} diff --git a/frontendJS/respotify/src/greeting.js b/frontendJS/respotify/src/greeting.js new file mode 100644 index 0000000..0f3f0c9 --- /dev/null +++ b/frontendJS/respotify/src/greeting.js @@ -0,0 +1,11 @@ +import React from "react"; + +export default React.createClass({ + render: function() { + return ( +
+ Hello, {this.props.name}! +
+ ) + } +}) \ No newline at end of file diff --git a/frontendJS/respotify/src/index.html b/frontendJS/respotify/src/index.html new file mode 100644 index 0000000..a889f34 --- /dev/null +++ b/frontendJS/respotify/src/index.html @@ -0,0 +1,12 @@ + + + + Respotify + + + +

Respotify

+
+ + + \ No newline at end of file diff --git a/frontendJS/respotify/src/index.js b/frontendJS/respotify/src/index.js new file mode 100644 index 0000000..4f0df5b --- /dev/null +++ b/frontendJS/respotify/src/index.js @@ -0,0 +1,8 @@ +import React from "react"; +import ReactDOM from "react-dom"; +import Greeting from "./greeting"; + +ReactDOM.render( + , + document.getElementById('container') +); \ No newline at end of file diff --git a/frontendJS/respotify/webpack.config.js b/frontendJS/respotify/webpack.config.js new file mode 100644 index 0000000..e1168e5 --- /dev/null +++ b/frontendJS/respotify/webpack.config.js @@ -0,0 +1,36 @@ +const webpack = require('webpack'); +const path = require('path'); + +const PATHS = { + app: './src/index.js', + html: './src/index.html', + dist: path.join(__dirname, 'dist') +}; + +module.exports = { + entry: { + javascript: PATHS.app, + html: PATHS.html + }, + output: { + path: PATHS.dist, + publicPath: '/', + filename: 'bundle.js' + }, + devServer: { + contentBase: PATHS.dist + }, + module: { + loaders: [ + { + test: /\.html$/, + loader: "file?name=[name].[ext]" + }, + { + test: /\.js$/, + exclude: /node_modules/, + loaders: ["react-hot", "babel-loader"] + } + ] + } +}; \ No newline at end of file