Add react/ frontend tutorials
This commit is contained in:
parent
a1422818ee
commit
a2c108af80
6
.gitignore
vendored
6
.gitignore
vendored
@ -677,4 +677,8 @@ fabric.properties
|
|||||||
*.ear
|
*.ear
|
||||||
|
|
||||||
# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
|
# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
|
||||||
hs_err_pid*
|
hs_err_pid*
|
||||||
|
|
||||||
|
# Ignore all the stupid idea garbage
|
||||||
|
**/.idea/**/*.xml
|
||||||
|
**/.idea/**/*.iml
|
50
frontendJS/full-stack-react-redux/voting-client/package.json
Normal file
50
frontendJS/full-stack-react-redux/voting-client/package.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
}
|
@ -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'
|
||||||
|
};
|
||||||
|
}
|
@ -0,0 +1,7 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export default React.createClass({
|
||||||
|
render: function() {
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
});
|
@ -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 ?
|
||||||
|
<Winner ref="winner" winner={this.props.winner} /> :
|
||||||
|
<div className="results">
|
||||||
|
<div className="tally">
|
||||||
|
{this.getPair().map(entry =>
|
||||||
|
<div key={entry} className="entry">
|
||||||
|
<h1>{entry}</h1>
|
||||||
|
<div className="voteVisualization">
|
||||||
|
<div className="votesBlock"
|
||||||
|
style={{width: this.getVotesBlockWidth(entry)}}>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="voteCount">
|
||||||
|
{this.getVotes(entry)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="management">
|
||||||
|
<button ref="next"
|
||||||
|
className="next"
|
||||||
|
onClick={this.props.next}>Next</button>
|
||||||
|
</div>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
@ -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 <div className="voting">
|
||||||
|
{this.getPair().map(entry =>
|
||||||
|
<button key={entry}
|
||||||
|
className={classNames({voted: this.hasVotedFor(entry)})}
|
||||||
|
disabled={this.isDisabled()}
|
||||||
|
onClick={() => this.props.vote(entry)}>
|
||||||
|
<h1>{entry}</h1>
|
||||||
|
{this.hasVotedFor(entry) ?
|
||||||
|
<div className="label">Voted</div> : null}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
});
|
@ -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 <div>
|
||||||
|
{this.props.winner
|
||||||
|
? <Winner ref="winner" winner={this.props.winner} />
|
||||||
|
: <Vote {...this.props} />}
|
||||||
|
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function mapStateToProps(state) {
|
||||||
|
return {
|
||||||
|
pair: state.getIn(['vote', 'pair']),
|
||||||
|
hasVoted: state.get('hasVoted'),
|
||||||
|
winner: state.get('winner')
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const VotingContainer = connect(mapStateToProps, actionCreators)(Voting);
|
@ -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 <div className="winner">
|
||||||
|
Winner is {this.props.winner}!
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
});
|
@ -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 = <Route component={App}>
|
||||||
|
<Route path="/results" component={ResultsContainer} />
|
||||||
|
<Route path="/" component={VotingContainer} />
|
||||||
|
</Route>;
|
||||||
|
|
||||||
|
ReactDOM.render(
|
||||||
|
<Provider store={store}>
|
||||||
|
<Router history={browserHistory}>{routes}</Router>
|
||||||
|
</Provider>,
|
||||||
|
document.getElementById('app')
|
||||||
|
);
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,6 @@
|
|||||||
|
export default socket => store => next => action => {
|
||||||
|
if (action.meta && action.meta.remote) {
|
||||||
|
socket.emit('action', action);
|
||||||
|
}
|
||||||
|
return next(action);
|
||||||
|
};
|
123
frontendJS/full-stack-react-redux/voting-client/src/style.css
Normal file
123
frontendJS/full-stack-react-redux/voting-client/src/style.css
Normal file
@ -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;
|
||||||
|
}
|
@ -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(
|
||||||
|
<Results pair={pair} tally={tally} />
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<Results pair={pair} tally={Map()} next={next} />
|
||||||
|
);
|
||||||
|
|
||||||
|
Simulate.click(ReactDOM.findDOMNode(component.refs.next));
|
||||||
|
|
||||||
|
expect(nextInvoked).to.equal(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the winner when there is one', () => {
|
||||||
|
const conponent = renderIntoDocument(
|
||||||
|
<Results winner="Trainspotting"
|
||||||
|
pair={["Trainspotting", "28 Days Later"]}
|
||||||
|
tally={Map()} />
|
||||||
|
);
|
||||||
|
const winner = ReactDOM.findDOMNode(conponent.refs.winner);
|
||||||
|
expect(winner).to.be.ok;
|
||||||
|
expect(winner.textContent).to.contain('Trainspotting');
|
||||||
|
});
|
||||||
|
});
|
@ -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(
|
||||||
|
<Voting pair={["Trainspotting", "28 Days Later"]} />
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<Voting pair={["Trainspotting", "28 Days Later"]} vote={vote} />
|
||||||
|
);
|
||||||
|
const buttons = scryRenderedDOMComponentsWithTag(component, 'button');
|
||||||
|
Simulate.click(buttons[0]);
|
||||||
|
|
||||||
|
expect(votedWith).to.equal('Trainspotting');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables buttons when user has voted', () => {
|
||||||
|
const component = renderIntoDocument(
|
||||||
|
<Voting pair={["Trainspotting", "28 Days Later"]}
|
||||||
|
hasVoted="Trainspotting" />
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<Voting pair={["Trainspotting", "28 Days Later"]}
|
||||||
|
hasVoted="Trainspotting" />
|
||||||
|
);
|
||||||
|
const buttons = scryRenderedDOMComponentsWithTag(component, 'button');
|
||||||
|
|
||||||
|
expect(buttons[0].textContent).to.contain('Voted');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders just the winner when there is one', () => {
|
||||||
|
const component = renderIntoDocument(
|
||||||
|
<Voting winner="Trainspotting" />
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<Voting pair={pair} />,
|
||||||
|
container
|
||||||
|
);
|
||||||
|
|
||||||
|
let firstButton = scryRenderedDOMComponentsWithTag(component, 'button')[0];
|
||||||
|
expect(firstButton.textContent).to.equal('Trainspotting');
|
||||||
|
|
||||||
|
pair[0] = 'Sunshine';
|
||||||
|
component = ReactDOM.render(
|
||||||
|
<Voting pair={pair} />,
|
||||||
|
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(
|
||||||
|
<Voting pair={pair} />,
|
||||||
|
container
|
||||||
|
);
|
||||||
|
|
||||||
|
let firstButton = scryRenderedDOMComponentsWithTag(component, 'button')[0];
|
||||||
|
expect(firstButton.textContent).to.equal('Trainspotting');
|
||||||
|
|
||||||
|
const newPair = pair.set(0, 'Sunshine');
|
||||||
|
component = ReactDOM.render(
|
||||||
|
<Voting pair={newPair} />,
|
||||||
|
container
|
||||||
|
);
|
||||||
|
firstButton = scryRenderedDOMComponentsWithTag(component, 'button')[0];
|
||||||
|
expect(firstButton.textContent).to.equal('Sunshine');
|
||||||
|
});
|
||||||
|
});
|
@ -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']
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
});
|
@ -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('<!DOCTYPE html><html><body></body></html>');
|
||||||
|
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);
|
@ -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'
|
||||||
|
};
|
13
frontendJS/full-stack-react-redux/voting-server/entries.json
Normal file
13
frontendJS/full-stack-react-redux/voting-server/entries.json
Normal file
@ -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"
|
||||||
|
]
|
23
frontendJS/full-stack-react-redux/voting-server/index.js
Normal file
23
frontendJS/full-stack-react-redux/voting-server/index.js
Normal file
@ -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'});
|
||||||
|
|
32
frontendJS/full-stack-react-redux/voting-server/package.json
Normal file
32
frontendJS/full-stack-react-redux/voting-server/package.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
}
|
46
frontendJS/full-stack-react-redux/voting-server/src/core.js
Normal file
46
frontendJS/full-stack-react-redux/voting-server/src/core.js
Normal file
@ -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
|
||||||
|
);
|
||||||
|
}
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
@ -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));
|
||||||
|
});
|
||||||
|
}
|
@ -0,0 +1,6 @@
|
|||||||
|
import {createStore} from 'redux';
|
||||||
|
import reducer from './reducer';
|
||||||
|
|
||||||
|
export default function makeStore() {
|
||||||
|
return createStore(reducer);
|
||||||
|
}
|
@ -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
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -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'
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -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'
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
});
|
@ -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']
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
@ -0,0 +1,4 @@
|
|||||||
|
import chai from 'chai';
|
||||||
|
import chaiImmutable from 'chai-immutable';
|
||||||
|
|
||||||
|
chai.use(chaiImmutable);
|
3
frontendJS/respotify/.babelrc
Normal file
3
frontendJS/respotify/.babelrc
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"presets": ["react", "es2015"]
|
||||||
|
}
|
28
frontendJS/respotify/package.json
Normal file
28
frontendJS/respotify/package.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
}
|
11
frontendJS/respotify/src/greeting.js
Normal file
11
frontendJS/respotify/src/greeting.js
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
export default React.createClass({
|
||||||
|
render: function() {
|
||||||
|
return (
|
||||||
|
<div className="greeting">
|
||||||
|
Hello, {this.props.name}!
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
12
frontendJS/respotify/src/index.html
Normal file
12
frontendJS/respotify/src/index.html
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Respotify</title>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Respotify</h1>
|
||||||
|
<div id="container"></div>
|
||||||
|
<script src="/bundle.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
8
frontendJS/respotify/src/index.js
Normal file
8
frontendJS/respotify/src/index.js
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import React from "react";
|
||||||
|
import ReactDOM from "react-dom";
|
||||||
|
import Greeting from "./greeting";
|
||||||
|
|
||||||
|
ReactDOM.render(
|
||||||
|
<Greeting name="World" />,
|
||||||
|
document.getElementById('container')
|
||||||
|
);
|
36
frontendJS/respotify/webpack.config.js
Normal file
36
frontendJS/respotify/webpack.config.js
Normal file
@ -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"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
Loading…
Reference in New Issue
Block a user