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
|
||||
|
||||
# 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