├── .babelrc ├── .eslintrc ├── .gitignore ├── README.md ├── package.json ├── public ├── build.html └── index.html ├── src ├── Root.js ├── actions │ ├── FunFairShiftActions.js │ ├── GenericActions.js │ └── SemaphoreActions.js ├── components │ ├── Comment.js │ ├── Comments.js │ ├── FunFairShift.js │ ├── FunFairShifts.js │ ├── Semaphore.js │ ├── SemaphoreStateForm.js │ └── Semaphores.js ├── constants │ ├── ActionTypes.js │ └── index.js ├── container │ └── FunFairShiftsApp.js ├── index.js ├── middleware │ ├── api.js │ └── throttle.js ├── reducers │ ├── funFairShifts.js │ ├── index.js │ └── semaphores.js ├── scss │ └── stylesheet.scss └── util │ ├── helpers.js │ └── templateHelpers.js ├── stub ├── semaphores.json ├── shifts.json └── state.json └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "stage": 0, 3 | "plugins": ["lodash"] 4 | } 5 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "env": { 4 | "browser": true, 5 | "es6": true 6 | }, 7 | "ecmaFeatures": { 8 | "modules": true, 9 | "jsx": true 10 | }, 11 | "rules": { 12 | "strict": 0, 13 | "semi": [2, "always"] 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build 2 | .idea 3 | *.tar 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React + Redux + React-Router demo 2 | 3 | ## Introduction 4 | 5 | Since I got excited about the React + Redux combo, I've tried it out on a current project. It was working well for me, 6 | so I've extracted relevant parts out into this demo. 7 | 8 | This demo application represents a "fun fair" (amusement park) with two daily shifts (morning and afternoon), while each 9 | shift has a group of semaphores to indicate the queue length (of people waiting for the attraction). 10 | The semaphores are updated automatically server-side (just polling every 5 seconds in this demo), but employees can 11 | also change the state of a semaphore by clicking on it and submitting the form. 12 | 13 | ## JS stack 14 | 15 | * ES6, Webpack, Babel 16 | * React 0.14.0-beta1 + Redux 1.0.0 + React-router 1.0.0-beta3 17 | * [fetch](https://github.com/github/fetch) 18 | * [classnames](https://github.com/JedWatson/classnames) 19 | * Lodash + [babel-plugin-lodash](https://github.com/megawac/babel-plugin-lodash) 20 | 21 | ## CSS stack 22 | 23 | * SASS (node-sass) 24 | * PureCSS 25 | * PostCSS + Autoprefixer 26 | 27 | ## Features 28 | 29 | * Async actions (i.e. XHRs) 30 | * Middleware 31 | * [redux-thunk](https://github.com/gaearon/redux-thunk) 32 | * [redux-logger](https://github.com/fcomb/redux-logger) 33 | * Custom "api" middleware for XHRs 34 | * Custom "throttle" middleware 35 | * Basic development/production toggle 36 | * `__DEV__`: react-hot-loader, logger middleware 37 | * Non-`__DEV__`: optimize with UglifyJS 38 | * CSS is compiled separately 39 | 40 | ## Development 41 | 42 | npm install 43 | npm run dev 44 | 45 | Go to [http://localhost:8080/public](http://localhost:8080/public). 46 | 47 | ## Production build 48 | 49 | npm run build 50 | 51 | Find build at `./build/bundle.js`. 52 | 53 | ## TODO 54 | 55 | * Improve on applying Redux architecture 56 | * Better error handling 57 | * Input validation 58 | * Better config options 59 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "FunFair", 3 | "version": "1.0.0", 4 | "main": "src/index.js", 5 | "scripts": { 6 | "build": "mkdir -p build/css && node-sass src/scss/stylesheet.scss --output-style=compressed | postcss --use autoprefixer -o build/css/stylesheet.css && BUILD_RELEASE=1 webpack -p", 7 | "dev": "node-sass -rw src/scss src/scss/stylesheet.scss -o build/css | postcss --use autoprefixer -o build/css/stylesheet.css & webpack-dev-server", 8 | "postbuild": "cp -R node_modules/font-awesome/fonts build/; cp public/build.html build/index.html", 9 | "postinstall": "npm run build", 10 | "lint": "eslint src" 11 | }, 12 | "dependencies": { 13 | "classnames": "^2.1.3", 14 | "font-awesome": "^4.4.0", 15 | "lodash": "^3.10.1", 16 | "purecss": "^0.6.0", 17 | "react": "0.14.0-beta1", 18 | "react-redux": "^0.8.2", 19 | "react-router": "1.0.0-beta3", 20 | "redux": "^1.0.0", 21 | "redux-logger": "0.0.3", 22 | "redux-thunk": "^0.1.0", 23 | "whatwg-fetch": "^0.9.0" 24 | }, 25 | "devDependencies": { 26 | "autoprefixer": "^5.2.0", 27 | "babel-core": "^5.8.20", 28 | "babel-eslint": "^4.0.5", 29 | "babel-loader": "^5.3.2", 30 | "babel-plugin-lodash": "^0.1.5", 31 | "eslint": "^1.1.0", 32 | "extract-text-webpack-plugin": "^0.8.2", 33 | "imports-loader": "^0.6.4", 34 | "node-libs-browser": "^0.5.2", 35 | "node-sass": "^3.2.0", 36 | "postcss-cli": "^1.5.0", 37 | "react": "^0.13.3", 38 | "react-hot-api": "0.4.5", 39 | "react-hot-loader": "^1.2.8", 40 | "webpack": "^1.10.5", 41 | "webpack-dev-server": "^1.10.1" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /public/build.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Fun Fair 6 | 7 | 8 | 9 | 10 |
11 | 12 | 32 | 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Fun Fair 6 | 7 | 8 | 9 | 10 |
11 | 12 | 32 | 33 | 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /src/Root.js: -------------------------------------------------------------------------------- 1 | import React, { Component, PropTypes } from 'react'; 2 | import { Redirect, Router, Route } from 'react-router'; 3 | import { createStore, combineReducers, applyMiddleware } from 'redux'; 4 | import { Provider } from 'react-redux'; 5 | import thunk from 'redux-thunk'; 6 | import logger from 'redux-logger'; 7 | import api from './middleware/api'; 8 | import throttle from './middleware/throttle'; 9 | import * as reducers from './reducers'; 10 | import FunFairShiftsApp from './container/FunFairShiftsApp'; 11 | import FunFairShifts from './components/FunFairShifts'; 12 | import Semaphores from './components/Semaphores'; 13 | 14 | let middleware = [thunk, throttle, api]; 15 | if(__DEV__) middleware.push(logger); 16 | 17 | const createStoreWithMiddleware = applyMiddleware(...middleware)(createStore), 18 | reducer = combineReducers(reducers), 19 | store = createStoreWithMiddleware(reducer); 20 | 21 | export default class Root extends Component { 22 | static propTypes = { 23 | history: PropTypes.object.isRequired 24 | }; 25 | 26 | render() { 27 | const { history } = this.props; 28 | return ( 29 | 30 | {renderRoutes.bind(null, history)} 31 | 32 | ); 33 | } 34 | } 35 | 36 | function renderRoutes(history) { 37 | return ( 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /src/actions/FunFairShiftActions.js: -------------------------------------------------------------------------------- 1 | import { FUNFAIRSHIFTS_REQUEST, FUNFAIRSHIFTS_SELECT } from '../constants/ActionTypes'; 2 | 3 | function fetchShifts() { 4 | return { 5 | type: FUNFAIRSHIFTS_REQUEST, 6 | payload: { 7 | url: FUNFAIR_CONFIG.API.current['shifts'] 8 | }, 9 | meta: { 10 | throttle: 2000 11 | } 12 | } 13 | } 14 | 15 | export function loadShifts() { 16 | return dispatch => { 17 | return dispatch(fetchShifts()); 18 | }; 19 | } 20 | -------------------------------------------------------------------------------- /src/actions/GenericActions.js: -------------------------------------------------------------------------------- 1 | import { FUNFAIRSHIFTS_SELECT } from '../constants/ActionTypes'; 2 | 3 | export default { 4 | setSelectedShift(shiftId) { 5 | return { 6 | type: FUNFAIRSHIFTS_SELECT, 7 | payload: { 8 | shiftId: shiftId 9 | } 10 | }; 11 | } 12 | }; 13 | -------------------------------------------------------------------------------- /src/actions/SemaphoreActions.js: -------------------------------------------------------------------------------- 1 | import { SEMAPHORES_REQUEST, SEMAPHORE_HOVER, SEMAPHORE_LOCK, SEMAPHORE_STATE_SUBMIT } from '../constants/ActionTypes.js'; 2 | 3 | function fetchSemaphores(getState) { 4 | 5 | let state = getState(), 6 | { selectedShiftId } = state.funFairShifts; 7 | 8 | return { 9 | type: SEMAPHORES_REQUEST, 10 | payload: { 11 | url: `${FUNFAIR_CONFIG.API.current['semaphores']}/${selectedShiftId}` 12 | }, 13 | meta: { 14 | throttle: 2000 15 | } 16 | } 17 | } 18 | 19 | function storeSemaphoreState(data) { 20 | 21 | let url = `${FUNFAIR_CONFIG.API.current['state']}/${data.SHIFT_ID}/${data.SEMAPHORE_ID}`; 22 | 23 | return { 24 | type: SEMAPHORE_STATE_SUBMIT, 25 | payload: { 26 | url: url, 27 | method: url.indexOf('stub') === -1 ? 'POST' : 'GET', 28 | data: data 29 | } 30 | } 31 | } 32 | 33 | export default { 34 | 35 | loadSemaphores() { 36 | return (dispatch, getState) => { 37 | return dispatch(fetchSemaphores(getState)); 38 | }; 39 | }, 40 | 41 | selectSemaphore(semaphoreId) { 42 | return { 43 | type: SEMAPHORE_HOVER, 44 | payload: { 45 | semaphoreId: semaphoreId 46 | } 47 | }; 48 | }, 49 | 50 | lockSemaphore(semaphoreId, requestState) { 51 | return { 52 | type: SEMAPHORE_LOCK, 53 | payload: { 54 | semaphoreId: semaphoreId, 55 | semaphoreState: requestState 56 | } 57 | }; 58 | }, 59 | 60 | saveSemaphoreState(data) { 61 | return dispatch => { 62 | return dispatch(storeSemaphoreState(data)); 63 | }; 64 | } 65 | }; 66 | -------------------------------------------------------------------------------- /src/components/Comment.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import classNames from 'classnames'; 3 | import { formatTime } from '../util/templateHelpers'; 4 | import { SEMAPHORE_MAP } from '../constants'; 5 | 6 | export default class Comment extends React.Component { 7 | 8 | render() { 9 | 10 | let comment = this.props.comment, 11 | classes = classNames('comment-state', SEMAPHORE_MAP[comment.STATE]); 12 | 13 | return ( 14 |

{formatTime(comment.TIMESTAMP)} {comment.REASON} - {comment.ACTION}

15 | ); 16 | } 17 | 18 | }; 19 | -------------------------------------------------------------------------------- /src/components/Comments.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Comment from './Comment'; 3 | import { connect } from 'react-redux'; 4 | 5 | export default class Comments extends React.Component { 6 | 7 | render() { 8 | 9 | let semaphore = this.props.semaphore; 10 | 11 | if(!semaphore) return null; 12 | 13 | let comments = semaphore.STATES.map((comment, i) => ); 14 | 15 | return ( 16 |
{comments}
17 | ); 18 | } 19 | 20 | } 21 | -------------------------------------------------------------------------------- /src/components/FunFairShift.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import classNames from 'classnames'; 3 | import { Link } from 'react-router'; 4 | import { formatDate, formatTime } from '../util/templateHelpers'; 5 | import { STATUS_MAP, CLASSNAME_HIGHLIGHT } from '../constants'; 6 | 7 | let ReactPropTypes = React.PropTypes; 8 | 9 | const today = formatDate(new Date().toISOString()); 10 | 11 | export default class Shift extends React.Component { 12 | 13 | render() { 14 | 15 | let shift = this.props.shift; 16 | 17 | let classes = { 18 | today: today === formatDate(shift.START) ? CLASSNAME_HIGHLIGHT : '' 19 | }; 20 | 21 | return ( 22 | 23 | {shift.ID} 24 | {formatDate(shift.START)} 25 | {shift.TYPE} 26 | {formatTime(shift.START)} 27 | {formatTime(shift.END)} 28 | 29 | ); 30 | } 31 | }; 32 | 33 | Shift.propTypes = { 34 | shift: ReactPropTypes.object.isRequired 35 | }; 36 | -------------------------------------------------------------------------------- /src/components/FunFairShifts.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { connect } from 'react-redux'; 3 | import { Link } from 'react-router'; 4 | import { loadShifts } from '../actions/FunFairShiftActions'; 5 | import FunFairShift from './FunFairShift'; 6 | import { FUNFAIRSHIFTS_SORT } from '../constants/ActionTypes'; 7 | import _ from 'lodash'; 8 | 9 | @connect(state => state.funFairShifts) 10 | export default class FunFairShifts extends React.Component { 11 | 12 | componentWillMount() { 13 | this.props.dispatch(loadShifts()); 14 | } 15 | 16 | componentWillReceiveProps(nextProps) { 17 | let sortKey = _.get(nextProps, 'location.query.sort'); 18 | if(sortKey === nextProps.sortKey) return; 19 | this.props.dispatch({ 20 | type: FUNFAIRSHIFTS_SORT, 21 | payload: { 22 | sortKey: sortKey 23 | } 24 | }); 25 | } 26 | 27 | getSortableKeys(shift) { 28 | return _.mapValues(shift, (value, key) => key === this.props.sortKey ? '-' + key : key); 29 | } 30 | 31 | render() { 32 | 33 | let shifts = this.props.shifts; 34 | 35 | if(shifts.length === 0) return null; 36 | 37 | let shiftComponents = shifts.map(shift => ); 38 | 39 | let sort = this.getSortableKeys(shifts[0]); 40 | 41 | return ( 42 |
43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | {shiftComponents} 55 | 56 |
IDDATETYPESTARTEND
57 | 58 |
59 | ); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/components/Semaphore.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { connect } from 'react-redux'; 3 | import SemaphoreActions from '../actions/SemaphoreActions'; 4 | import { getCurrentState } from '../reducers/semaphores'; 5 | import classNames from 'classnames'; 6 | import { formatTime } from '../util/templateHelpers'; 7 | import { SEMAPHORE_MAP, INDICATOR_SHORT, INDICATOR_MEDIUM, INDICATOR_LONG, CLASSNAME_SHORT, CLASSNAME_MEDIUM, CLASSNAME_LONG } from '../constants'; 8 | 9 | @connect((state, props) => { 10 | return { 11 | semaphore: state.semaphores.semaphores[props.id], 12 | lockedSemaphoreId: state.semaphores.lockedSemaphoreId, 13 | lockedSemaphoreState: state.semaphores.lockedSemaphoreState 14 | }; 15 | }) 16 | export default class Semaphore extends React.Component { 17 | 18 | handleMouseEnter() { 19 | this.props.dispatch(SemaphoreActions.selectSemaphore(this.props.id)); 20 | } 21 | 22 | handleClick(evt) { 23 | this.props.dispatch(SemaphoreActions.lockSemaphore(this.props.id, evt.target.getAttribute('data-state'))); 24 | } 25 | 26 | render() { 27 | 28 | const { semaphore, lockedSemaphoreId, lockedSemaphoreState } = this.props; 29 | 30 | if(!semaphore) { 31 | return (
); 32 | } 33 | 34 | let classes = classNames(['semaphore', SEMAPHORE_MAP[getCurrentState(semaphore)], SEMAPHORE_MAP[semaphore.ID === lockedSemaphoreId ? lockedSemaphoreState : null]]); 35 | 36 | let lineClass = this.props.lines ? ['line', ...this.props.lines.split(' ').map(l => `line-${l}`)].join(' ') : ''; 37 | 38 | return ( 39 |
40 |
41 | 42 | 43 | 44 |
45 |
{semaphore.TITLE}
{formatTime(this.props.tobt)}
46 |
47 | ); 48 | } 49 | 50 | } 51 | -------------------------------------------------------------------------------- /src/components/SemaphoreStateForm.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { connect } from 'react-redux'; 3 | import { saveSemaphoreState } from '../actions/SemaphoreActions'; 4 | import Comments from './Comments'; 5 | 6 | function validate(data) { 7 | let invalid = {}; 8 | if(data.SEMAPHORE_REASON === undefined || data.SEMAPHORE_REASON === '') { 9 | invalid.reason = true; 10 | } 11 | if(data.SEMAPHORE_ACTION === undefined || data.SEMAPHORE_ACTION === '') { 12 | invalid.action = true; 13 | } 14 | return Object.keys(invalid).length === 0 ? false : invalid; 15 | } 16 | 17 | @connect(state => state) 18 | export default class SemaphoreStateForm extends React.Component { 19 | 20 | handleInput() { 21 | 22 | var data = { 23 | SHIFT_ID: this.props.funFairShifts.selectedShiftId, 24 | SEMAPHORE_ID: this.props.semaphores.lockedSemaphoreId, 25 | SEMAPHORE_REASON: this.refs.reason.getDOMNode().value, 26 | SEMAPHORE_ACTION: this.refs.action.getDOMNode().value, 27 | SEMAPHORE_STATE: this.props.semaphores.lockedSemaphoreState 28 | }; 29 | 30 | let invalid = validate(data); 31 | 32 | if(invalid) { 33 | (this.refs.reason.getDOMNode()).classList[invalid.reason ? 'add' : 'remove']('invalid'); 34 | (this.refs.action.getDOMNode()).classList[invalid.action ? 'add' : 'remove']('invalid'); 35 | } 36 | 37 | return [data, invalid]; 38 | } 39 | 40 | handleSend(evt) { 41 | evt.preventDefault(); 42 | 43 | let [data, invalid] = this.handleInput(); 44 | 45 | if(!invalid) { 46 | this.props.dispatch(saveSemaphoreState(data)); 47 | this.refs.submitButton.getDOMNode().disabled = true; 48 | } 49 | } 50 | 51 | shouldComponentUpdate(nextProps, nextState) { 52 | if(this.props.semaphores.hoveredSemaphoreId !== nextProps.semaphores.hoveredSemaphoreId) return false; 53 | return true; 54 | } 55 | 56 | render() { 57 | 58 | let semaphore = this.props.semaphores.semaphores[this.props.semaphores.lockedSemaphoreId]; 59 | 60 | if(!semaphore) return null; 61 | 62 | return ( 63 |
64 |
65 | 66 |

{semaphore.TITLE}

67 | 68 |
69 |
70 |
71 |
72 | 73 |

Please provide a reason.

74 |
75 |
76 | 77 |

Please provide an action.

78 |
79 |
80 | 81 |
82 |
83 |
84 |
85 | 86 | 87 | 88 |
89 | ); 90 | } 91 | 92 | } 93 | -------------------------------------------------------------------------------- /src/components/Semaphores.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { connect } from 'react-redux'; 3 | import GenericActions from '../actions/GenericActions'; 4 | import { loadShifts } from '../actions/FunFairShiftActions'; 5 | import { loadSemaphores } from '../actions/SemaphoreActions'; 6 | import Semaphore from './Semaphore'; 7 | import SemaphoreStateForm from './SemaphoreStateForm'; 8 | import Comments from './Comments'; 9 | import _ from 'lodash'; 10 | import { formatDate, formatTime } from '../util/templateHelpers'; 11 | 12 | @connect(state => state) 13 | export default class Semaphores extends React.Component { 14 | 15 | componentWillMount() { 16 | this.props.dispatch(GenericActions.setSelectedShift(this.props.params.SHIFT_ID)); 17 | this.props.dispatch(loadShifts()); 18 | this.setRefreshInterval(); 19 | } 20 | 21 | setRefreshInterval() { 22 | this.clearRefreshInterval(); 23 | this._refreshInterval = setInterval(::this.refresh, 60 * 1000); 24 | this.refresh(); 25 | } 26 | 27 | clearRefreshInterval() { 28 | clearInterval(this._refreshInterval); 29 | } 30 | 31 | refresh() { 32 | this.props.dispatch(loadSemaphores()); 33 | } 34 | 35 | componentWillUnmount() { 36 | clearInterval(this._refreshInterval); 37 | } 38 | 39 | render() { 40 | 41 | let shift = _.find(this.props.funFairShifts.shifts, {ID: this.props.funFairShifts.selectedShiftId}) || {}; 42 | 43 | let semaphore = this.props.semaphores.semaphores[this.props.semaphores.hoveredSemaphoreId], 44 | semaphoreTitle = semaphore ? (

{semaphore.TITLE}

) : null; 45 | 46 | let line = 'line line-horizontal'; 47 | 48 | return ( 49 |
50 | 51 |

{formatDate(shift.START)} {shift.TYPE} ({formatTime(shift.START)}-{formatTime(shift.END)})

52 | 53 |
54 |
55 |
56 | 57 |
58 | 59 | 60 |
61 | 62 |
63 |
64 |
65 |
66 | 67 | 68 | 69 |
70 | 71 |
72 | 73 |
74 |
75 |
76 |
77 | 78 |
79 | 80 |
81 | 82 |
83 |
84 |
85 | 86 | 87 | 88 |
89 | 90 | {semaphoreTitle} 91 | 92 | 93 | 94 |
95 | 96 | 97 |
98 | ); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/constants/ActionTypes.js: -------------------------------------------------------------------------------- 1 | export const FUNFAIRSHIFTS_REQUEST = 'FUNFAIRSHIFTS_REQUEST'; 2 | export const FUNFAIRSHIFTS_SORT = 'FUNFAIRSHIFTS_SORT'; 3 | export const FUNFAIRSHIFTS_SELECT = 'FUNFAIRSHIFTS_SELECT'; 4 | export const SEMAPHORES_REQUEST = 'SEMAPHORES_REQUEST'; 5 | export const SEMAPHORE_HOVER = 'SEMAPHORE_HOVER'; 6 | export const SEMAPHORE_LOCK = 'SEMAPHORE_LOCK'; 7 | export const SEMAPHORE_STATE_SUBMIT = 'SEMAPHORE_STATE_SUBMIT'; 8 | -------------------------------------------------------------------------------- /src/constants/index.js: -------------------------------------------------------------------------------- 1 | export const CLASSNAME_SHORT = 'short'; 2 | export const CLASSNAME_MEDIUM = 'medium'; 3 | export const CLASSNAME_LONG = 'long'; 4 | export const CLASSNAME_HIGHLIGHT = 'highlight'; 5 | 6 | export const INDICATOR_SHORT = 'SHORT'; 7 | export const INDICATOR_MEDIUM = 'MEDIUM'; 8 | export const INDICATOR_LONG = 'LONG'; 9 | 10 | export const STATUS_MAP = {}; 11 | STATUS_MAP[INDICATOR_SHORT] = CLASSNAME_SHORT; 12 | STATUS_MAP[INDICATOR_MEDIUM] = CLASSNAME_MEDIUM; 13 | STATUS_MAP[INDICATOR_LONG] = CLASSNAME_LONG; 14 | 15 | export const SEMAPHORE_MAP = {}; 16 | SEMAPHORE_MAP[INDICATOR_SHORT] = CLASSNAME_SHORT; 17 | SEMAPHORE_MAP[INDICATOR_MEDIUM] = CLASSNAME_MEDIUM; 18 | SEMAPHORE_MAP[INDICATOR_LONG] = CLASSNAME_LONG; 19 | 20 | export const INDICATOR_PRIORITY = [INDICATOR_LONG, INDICATOR_MEDIUM, INDICATOR_SHORT]; 21 | -------------------------------------------------------------------------------- /src/container/FunFairShiftsApp.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { connect } from 'react-redux'; 3 | 4 | @connect(state => state) 5 | export default class FunFairShiftsApp extends React.Component { 6 | 7 | render() { 8 | return ( 9 |
10 | {this.props.children} 11 |
12 | ); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import HashHistory from 'react-router/lib/HashHistory'; 3 | import Root from './Root'; 4 | 5 | let env = __DEV__ ? 'local' : 'production'; 6 | 7 | FUNFAIR_CONFIG.API.current = {}; 8 | for(let key in FUNFAIR_CONFIG.API[env]) { 9 | FUNFAIR_CONFIG.API.current[key] = FUNFAIR_CONFIG.API[env].host + FUNFAIR_CONFIG.API[env][key]; 10 | } 11 | 12 | const history = new HashHistory(); 13 | 14 | React.render(, document.getElementById('app')); 15 | -------------------------------------------------------------------------------- /src/middleware/api.js: -------------------------------------------------------------------------------- 1 | import 'whatwg-fetch'; 2 | import { getRequestHeaders } from '../util/helpers.js'; 3 | 4 | function checkStatus(response) { 5 | if(response.status >= 200 && response.status < 300) { 6 | return response 7 | } else { 8 | var error = new Error(response.statusText); 9 | error.response = response; 10 | throw error; 11 | } 12 | } 13 | 14 | function request({ url, method, data }, successCallback, errorCallback) { 15 | 16 | fetch(url, { 17 | method: method || 'GET', 18 | headers: getRequestHeaders(), 19 | body: data ? JSON.stringify(data) : undefined 20 | }) 21 | .then(checkStatus) 22 | .then(response => response.json()) 23 | .then(successCallback) 24 | .catch(errorCallback); 25 | } 26 | 27 | export default store => dispatch => action => { 28 | 29 | if(!action.payload || !action.payload.url) { 30 | return dispatch(action); 31 | } 32 | 33 | const { type } = action; 34 | const { url, method, data } = action.payload; 35 | 36 | dispatch({type: type}); 37 | 38 | return request({url, method, data}, payload => dispatch({ 39 | type: type + '_SUCCESS', 40 | payload 41 | }), err => dispatch({ 42 | type: type + '_ERROR', 43 | error: err.message || 'Unknown', 44 | status: (err.response && err.response.status) || 0 45 | })); 46 | }; 47 | -------------------------------------------------------------------------------- /src/middleware/throttle.js: -------------------------------------------------------------------------------- 1 | let cache = {}; 2 | 3 | export default store => dispatch => action => { 4 | 5 | if (!action.type || !action.meta || !action.meta.throttle) { 6 | return dispatch(action); 7 | } 8 | 9 | const { type, meta } = action; 10 | 11 | if(cache[type]) { 12 | dispatch({type: type + '_THROTTLED'}); 13 | } else { 14 | cache[type] = true; 15 | setTimeout(() => delete cache[type], meta.throttle); 16 | return dispatch(action); 17 | } 18 | }; 19 | -------------------------------------------------------------------------------- /src/reducers/funFairShifts.js: -------------------------------------------------------------------------------- 1 | import { FUNFAIRSHIFTS_REQUEST, FUNFAIRSHIFTS_SORT, FUNFAIRSHIFTS_SELECT } from '../constants/ActionTypes'; 2 | import _ from 'lodash'; 3 | 4 | const initialState = { 5 | shifts: [], 6 | selectedShiftId: null, 7 | sortKey: '-START' 8 | }; 9 | 10 | const actionsMap = { 11 | [FUNFAIRSHIFTS_REQUEST + '_SUCCESS']: (state, action) => ({shifts: sortBy(action.payload.SHIFTS, state.sortKey)}), 12 | [FUNFAIRSHIFTS_SORT]: (state, action) => ({shifts: sortBy(state.shifts, action.payload.sortKey), sortKey: action.payload.sortKey}), 13 | [FUNFAIRSHIFTS_SELECT]: (state, action) => ({selectedShiftId: action.payload.shiftId}) 14 | }; 15 | 16 | const sortBy = (shifts, key) => { 17 | if(!key) return shifts; 18 | return _.sortByOrder(shifts, key.replace(/^-/, ''), key.indexOf('-') !== 0); 19 | }; 20 | 21 | export default function shifts(state = initialState, action) { 22 | const reduceFn = actionsMap[action.type]; 23 | if(!reduceFn) return state; 24 | 25 | return {...state, ...reduceFn(state, action)}; 26 | } 27 | -------------------------------------------------------------------------------- /src/reducers/index.js: -------------------------------------------------------------------------------- 1 | export funFairShifts from './funFairShifts'; 2 | export semaphores from './semaphores'; 3 | -------------------------------------------------------------------------------- /src/reducers/semaphores.js: -------------------------------------------------------------------------------- 1 | import { SEMAPHORES_REQUEST, SEMAPHORE_HOVER, SEMAPHORE_LOCK, SEMAPHORE_STATE_SUBMIT } from '../constants/ActionTypes'; 2 | import _ from 'lodash'; 3 | 4 | const initialState = { 5 | semaphores: {}, 6 | hoveredSemaphoreId: 'entrance', 7 | lockedSemaphoreId: null, 8 | lockedSemaphoreState: null 9 | }; 10 | 11 | const actionsMap = { 12 | [SEMAPHORES_REQUEST + '_SUCCESS']: (state, action) => ({semaphores: _.indexBy(action.payload.SEMAPHORES, 'ID')}), 13 | [SEMAPHORE_HOVER]: (state, action) => ({hoveredSemaphoreId: action.payload.semaphoreId}), 14 | [SEMAPHORE_LOCK]: (state, action) => ({lockedSemaphoreId: action.payload.semaphoreId, lockedSemaphoreState: action.payload.semaphoreState}), 15 | [SEMAPHORE_STATE_SUBMIT + '_SUCCESS']: (state, action) => ({lockedSemaphoreId: null, lockedSemaphoreState: null}) 16 | }; 17 | 18 | const getCurrentState = semaphore => { 19 | let state = _.find(semaphore.STATES, {CURRENT: 'Y'}); 20 | return state ? state.STATE : null; 21 | }; 22 | 23 | export default function semaphores(state = initialState, action) { 24 | const reduceFn = actionsMap[action.type]; 25 | if(!reduceFn) return state; 26 | 27 | return {...state, ...reduceFn(state, action)}; 28 | } 29 | 30 | export { getCurrentState }; 31 | -------------------------------------------------------------------------------- /src/scss/stylesheet.scss: -------------------------------------------------------------------------------- 1 | @import "../../node_modules/font-awesome/css/font-awesome"; 2 | @import "../../node_modules/purecss/build/pure"; 3 | 4 | $white: #FFF; 5 | $black: #000; 6 | $blue: rgb(66, 184, 221); 7 | $changed: $blue; 8 | $short: rgb(28, 184, 65); 9 | $medium: rgb(223, 117, 20); 10 | $long: rgb(202, 60, 60); 11 | $short_inactive: rgba(28, 184, 65, 1); 12 | $medium_inactive: rgba(223, 117, 20, 0.4); 13 | $long_inactive: rgba(202, 60, 60, 0.4); 14 | $highlight: rgb(252, 255, 105); 15 | 16 | * { 17 | box-sizing: border-box; 18 | } 19 | 20 | body { 21 | font-family: "Menlo", Helvetica, sans-serif; 22 | font-size: 16px; 23 | color: #333; 24 | } 25 | 26 | hr { 27 | height: 0; 28 | border: none; 29 | border-bottom: 1px solid #444; 30 | } 31 | 32 | .container { 33 | max-width: 960px; 34 | margin: 0 auto; 35 | } 36 | 37 | .hide { 38 | display: none !important; 39 | } 40 | 41 | .short, 42 | td.short, 43 | td.short a { 44 | background: $short; 45 | color: $white; 46 | } 47 | 48 | .medium, 49 | td.medium, 50 | td.medium a { 51 | background-color: $medium; 52 | color: $white; 53 | } 54 | 55 | .long, 56 | td.long, 57 | td.long a { 58 | background: $long; 59 | color: $white; 60 | } 61 | 62 | .short-text { 63 | color: $short; 64 | } 65 | 66 | .medium-text { 67 | color: $medium; 68 | } 69 | 70 | .long-text { 71 | color: $long; 72 | } 73 | 74 | .highlight { 75 | font-weight: bold; 76 | background-color: $highlight; 77 | } 78 | 79 | #shifts { 80 | margin-top: 1em; 81 | position: relative; 82 | } 83 | 84 | th[data-sort], td { 85 | cursor: pointer; 86 | } 87 | 88 | .hide-rows tbody tr:not(.open) { 89 | display: none; 90 | } 91 | 92 | .details-layer { 93 | padding: 2em 0 0; 94 | } 95 | 96 | .pure-table-minimal { 97 | border-collapse: separate; 98 | border-spacing: 3px; 99 | border: none; 100 | width: 100%; 101 | } 102 | 103 | .pure-table-minimal thead { 104 | background-color: inherit; 105 | } 106 | 107 | .pure-table-minimal tr:hover { 108 | background-color: #EEE; 109 | } 110 | 111 | .pure-table-minimal td, 112 | .pure-table-minimal th { 113 | border: none; 114 | } 115 | 116 | .pure-table-minimal th { 117 | padding-left: 2px; 118 | padding-right: 2px; 119 | } 120 | 121 | .pure-table-minimal td { 122 | padding: 2px; 123 | } 124 | 125 | .pure-table-minimal tbody > tr:last-child > td { 126 | border-bottom-width: 0; 127 | } 128 | 129 | .pure-table-minimal .short a, 130 | .pure-table-minimal .medium a, 131 | .pure-table-minimal .long a { 132 | text-decoration: none; 133 | display: block; 134 | } 135 | 136 | .boarding { 137 | width: 30px; 138 | color: $white; 139 | } 140 | 141 | .pure-button-wrap { 142 | white-space: normal; 143 | margin-top: 4px; 144 | } 145 | 146 | .pure-button-reload { 147 | float: right; 148 | background-color: $blue; 149 | color: $white; 150 | padding: 2px 4px; 151 | border-radius: 50%; 152 | } 153 | 154 | .sort-column { 155 | text-decoration: underline; 156 | } 157 | 158 | .flex-container { 159 | display: flex; 160 | flex-direction: row; 161 | } 162 | 163 | .comments div { 164 | flex: 1; 165 | text-align: center; 166 | &:nth-child(1), 167 | &:nth-child(2) { 168 | flex: 3; 169 | } 170 | } 171 | 172 | .semaphore-group { 173 | 174 | .row { 175 | padding: 0 5px; 176 | width: 100%; 177 | 178 | > div { 179 | flex: 1; 180 | text-align: center; 181 | position: relative; 182 | overflow: hidden; 183 | margin-left: -1px; 184 | 185 | div { 186 | min-height: 3em; 187 | } 188 | 189 | &.line { 190 | &::before { 191 | content: ''; 192 | position: absolute; 193 | top: 68px; 194 | left: 0; 195 | border-top: 1px solid #ccc; 196 | height: 0; 197 | z-index: -1; 198 | } 199 | &::after { 200 | content: ''; 201 | position: absolute; 202 | border-right: 1px solid #ccc; 203 | width: 0; 204 | z-index: -1; 205 | } 206 | } 207 | 208 | &.line-horizontal { 209 | &::before { 210 | width: 100%; 211 | } 212 | } 213 | 214 | &.line-left { 215 | &::before { 216 | width: 50%; 217 | } 218 | } 219 | 220 | &.line-right { 221 | &::before { 222 | width: 50%; 223 | left: 50%; 224 | } 225 | } 226 | 227 | &.line-upper-left { 228 | &::after { 229 | top: 0; 230 | left: 0; 231 | height: 68px; 232 | } 233 | } 234 | &.line-upper-right { 235 | &::after { 236 | top: 0; 237 | right: 0; 238 | height: 68px; 239 | } 240 | } 241 | &.line-lower-left { 242 | &::after { 243 | top: 68px; 244 | left: 0; 245 | height: 100%; 246 | } 247 | } 248 | &.line-lower-right { 249 | &::after { 250 | top: 68px; 251 | right: 0; 252 | height: 100%; 253 | } 254 | } 255 | &.line-vertical-right { 256 | &::after { 257 | top: 0; 258 | right: 0; 259 | height: 100%; 260 | } 261 | } 262 | &.line-vertical-left { 263 | &::after { 264 | top: 0; 265 | left: 0; 266 | height: 100%; 267 | } 268 | } 269 | } 270 | 271 | .semaphore { 272 | 273 | padding: 10px; 274 | margin: 0 auto; 275 | background: $black; 276 | border-radius: 5px; 277 | cursor: pointer; 278 | max-width: 70px; 279 | 280 | .long:hover, 281 | .medium:hover, 282 | .short:hover { 283 | opacity: 0.7; 284 | } 285 | 286 | &.long .long, 287 | &.medium .medium, 288 | &.short .short{ 289 | opacity: 1; 290 | } 291 | 292 | span { 293 | display: block; 294 | width: 2em; 295 | height: 2em; 296 | margin: 5px auto; 297 | border-radius: 50%; 298 | opacity: .4; 299 | 300 | &.long { 301 | background: red; 302 | background-image: radial-gradient(red, transparent); 303 | background-size: 2px 2px; 304 | box-shadow: 0 0 8px #111 inset, 0 0 4px red; 305 | } 306 | &.medium { 307 | background: orange; 308 | background-image: radial-gradient(orange, transparent); 309 | background-size: 2px 2px; 310 | box-shadow: 0 0 8px #111 inset, 0 0 4px orange; 311 | } 312 | &.short { 313 | background: green; 314 | background-image: radial-gradient(lime, transparent); 315 | background-size: 2px 2px; 316 | box-shadow: 0 0 8px #111 inset, 0 0 4px lime; 317 | } 318 | 319 | } 320 | 321 | &:after { 322 | content: ""; 323 | display: table; 324 | clear: both; 325 | } 326 | 327 | } 328 | 329 | .row-header { 330 | flex: 0.3; 331 | margin-top: 50px; 332 | font-size: 2em; 333 | } 334 | } 335 | 336 | } 337 | 338 | input, 339 | textarea, 340 | select, 341 | .error-message { 342 | width: 90%; 343 | margin: 3px auto; 344 | } 345 | 346 | input.invalid, 347 | textarea.invalid { 348 | border: 1px solid red; 349 | } 350 | 351 | input + .error-message, 352 | textarea + .error-message { 353 | text-align: left; 354 | color: red; 355 | display: none; 356 | } 357 | 358 | input.invalid + .error-message, 359 | textarea.invalid + .error-message { 360 | display: block; 361 | } 362 | 363 | .comment-state { 364 | display: inline-block; 365 | width: 12px; 366 | height: 12px; 367 | border-radius: 50%; 368 | } 369 | -------------------------------------------------------------------------------- /src/util/helpers.js: -------------------------------------------------------------------------------- 1 | const token = btoa('username' + ':' + 'password'); 2 | 3 | export function getRequestHeaders() { 4 | return { 5 | Authorization: 'Basic ' + token, 6 | Accept: 'application/json' 7 | }; 8 | } 9 | 10 | export function setRequestHeaders(xhr) { 11 | xhr.setRequestHeader('Authorization', 'Basic ' + token); 12 | xhr.setRequestHeader('Accept', 'application/json'); 13 | } 14 | -------------------------------------------------------------------------------- /src/util/templateHelpers.js: -------------------------------------------------------------------------------- 1 | function pad(num) { 2 | return ('0' + num).slice(-2); 3 | } 4 | 5 | export function formatDate(value) { 6 | if(!value) return ''; 7 | var d = new Date(value); 8 | return d instanceof Date && isFinite(d) ? `${pad(d.getUTCMonth()+1)}-${pad(d.getUTCDate())}` : ''; 9 | } 10 | 11 | export function formatTime(value) { 12 | if(!value) return ''; 13 | var d = new Date(value); 14 | return d instanceof Date && isFinite(d) ? `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}` : ''; 15 | } 16 | -------------------------------------------------------------------------------- /stub/semaphores.json: -------------------------------------------------------------------------------- 1 | { 2 | "SEMAPHORES": [ 3 | { 4 | "ID": "entrance", 5 | "TITLE": "Entrance", 6 | "STATES": [ 7 | { 8 | "STATE": "SHORT", 9 | "TIMESTAMP": "2015-08-12T10:31:00", 10 | "ACTION": "N/A", 11 | "CURRENT": "Y", 12 | "REASON": "Stand-in arrived, all booths staffed." 13 | }, 14 | { 15 | "STATE": "MEDIUM", 16 | "TIMESTAMP": "2015-08-12T10:03:00", 17 | "ACTION": "Call stand-in", 18 | "CURRENT": "N", 19 | "REASON": "Employee called in sick" 20 | }, 21 | { 22 | "STATE": "SHORT", 23 | "TIMESTAMP": "2015-08-12T10:00:00", 24 | "ACTION": "N/A", 25 | "CURRENT": "N", 26 | "REASON": "Initial", 27 | "REASON_BY": "System" 28 | } 29 | ] 30 | }, 31 | { 32 | "ID": "icecream", 33 | "TITLE": "Ice Cream", 34 | "STATES": [ 35 | { 36 | "STATE": "MEDIUM", 37 | "TIMESTAMP": "2015-08-12T11:10:00", 38 | "ACTION": "Get new ice cream", 39 | "CURRENT": "Y", 40 | "REASON": "Out of ice cream" 41 | }, 42 | { 43 | "STATE": "SHORT", 44 | "TIMESTAMP": "2015-08-12T10:00:00", 45 | "ACTION": "N/A", 46 | "CURRENT": "N", 47 | "REASON": "Initial", 48 | "REASON_BY": "System" 49 | } 50 | ] 51 | }, 52 | { 53 | "ID": "rollercoaster", 54 | "TITLE": "Roller Coaster", 55 | "STATES": [ 56 | { 57 | "STATE": "SHORT", 58 | "TIMESTAMP": "2015-08-12T10:00:00", 59 | "ACTION": "N/A", 60 | "CURRENT": "Y", 61 | "REASON": "Initial", 62 | "REASON_BY": "System" 63 | } 64 | ] 65 | }, 66 | { 67 | "ID": "waterride", 68 | "TITLE": "Water Ride", 69 | "STATES": [ 70 | { 71 | "STATE": "SHORT", 72 | "TIMESTAMP": "2015-08-12T10:00:00", 73 | "ACTION": "N/A", 74 | "CURRENT": "Y", 75 | "REASON": "Initial", 76 | "REASON_BY": "System" 77 | } 78 | ] 79 | }, 80 | { 81 | "ID": "burgers", 82 | "TITLE": "Burger Bar", 83 | "STATES": [ 84 | { 85 | "STATE": "SHORT", 86 | "TIMESTAMP": "2015-08-12T10:00:00", 87 | "ACTION": "N/A", 88 | "CURRENT": "Y", 89 | "REASON": "Initial", 90 | "REASON_BY": "System" 91 | } 92 | ] 93 | }, 94 | { 95 | "ID": "droptower", 96 | "TITLE": "Drop Tower", 97 | "STATES": [ 98 | { 99 | "STATE": "SHORT", 100 | "TIMESTAMP": "2015-08-12T10:00:00", 101 | "ACTION": "N/A", 102 | "CURRENT": "Y", 103 | "REASON": "Initial", 104 | "REASON_BY": "System" 105 | } 106 | ] 107 | }, 108 | { 109 | "ID": "wipeout", 110 | "TITLE": "Wipeout", 111 | "STATES": [ 112 | { 113 | "STATE": "LONG", 114 | "TIMESTAMP": "2015-08-12T11:08:00", 115 | "ACTION": "Install backup system", 116 | "CURRENT": "Y", 117 | "REASON": "System was wiped out!" 118 | }, 119 | { 120 | "STATE": "SHORT", 121 | "TIMESTAMP": "2015-08-12T10:00:00", 122 | "ACTION": "N/A", 123 | "CURRENT": "N", 124 | "REASON": "Initial", 125 | "REASON_BY": "System" 126 | } 127 | ] 128 | }, 129 | { 130 | "ID": "milkshakes", 131 | "TITLE": "Milkshakes", 132 | "STATES": [ 133 | { 134 | "STATE": "SHORT", 135 | "TIMESTAMP": "2015-08-12T10:00:00", 136 | "ACTION": "N/A", 137 | "CURRENT": "Y", 138 | "REASON": "Initial", 139 | "REASON_BY": "System" 140 | } 141 | ] 142 | }, 143 | { 144 | "ID": "carousel", 145 | "TITLE": "Carousel", 146 | "STATES": [ 147 | { 148 | "STATE": "MEDIUM", 149 | "TIMESTAMP": "2015-08-12T12:16:00", 150 | "ACTION": "Find the horse!", 151 | "CURRENT": "Y", 152 | "REASON": "One of the horses ran way" 153 | }, 154 | { 155 | "STATE": "SHORT", 156 | "TIMESTAMP": "2015-08-12T10:00:00", 157 | "ACTION": "N/A", 158 | "CURRENT": "N", 159 | "REASON": "Initial", 160 | "REASON_BY": "System" 161 | } 162 | ] 163 | }, 164 | { 165 | "ID": "hurricane", 166 | "TITLE": "Hurricane", 167 | "STATES": [ 168 | { 169 | "STATE": "MEDIUM", 170 | "TIMESTAMP": "2015-08-12T10:00:00", 171 | "ACTION": "Direct people to roller coaster", 172 | "CURRENT": "Y", 173 | "REASON": "This attraction is popular today" 174 | }, 175 | { 176 | "STATE": "SHORT", 177 | "TIMESTAMP": "2015-08-12T10:00:00", 178 | "ACTION": "N/A", 179 | "CURRENT": "N", 180 | "REASON": "Initial", 181 | "REASON_BY": "System" 182 | } 183 | ] 184 | }, 185 | { 186 | "ID": "bungee", 187 | "TITLE": "Bungee Jump", 188 | "STATES": [ 189 | { 190 | "STATE": "MEDIUM", 191 | "TIMESTAMP": "2015-08-12T10:00:00", 192 | "ACTION": "Jump quicker", 193 | "CURRENT": "Y", 194 | "REASON": "Cord fixed" 195 | }, 196 | { 197 | "STATE": "LONG", 198 | "TIMESTAMP": "2015-08-12T10:00:00", 199 | "ACTION": "Fix cord", 200 | "CURRENT": "N", 201 | "REASON": "Broken cord" 202 | }, 203 | { 204 | "STATE": "SHORT", 205 | "TIMESTAMP": "2015-08-12T10:00:00", 206 | "ACTION": "N/A", 207 | "CURRENT": "N", 208 | "REASON": "Initial", 209 | "REASON_BY": "System" 210 | } 211 | ] 212 | }, 213 | { 214 | "ID": "exit", 215 | "TITLE": "Exit", 216 | "STATES": [ 217 | { 218 | "STATE": "SHORT", 219 | "TIMESTAMP": "2015-08-12T10:00:00", 220 | "ACTION": "N/A", 221 | "CURRENT": "Y", 222 | "REASON": "Initial", 223 | "REASON_BY": "System" 224 | } 225 | ] 226 | } 227 | ] 228 | } 229 | -------------------------------------------------------------------------------- /stub/shifts.json: -------------------------------------------------------------------------------- 1 | { 2 | "SHIFTS": [ 3 | { 4 | "ID": "1", 5 | "TYPE": "MORNING", 6 | "START": "2015-08-07T10:00:00", 7 | "END": "2015-08-07T13:00:00" 8 | }, 9 | { 10 | "ID": "2", 11 | "TYPE": "AFTERNOON", 12 | "START": "2015-08-07T14:00:00", 13 | "END": "2015-08-07T17:00:00" 14 | }, 15 | { 16 | "ID": "3", 17 | "TYPE": "MORNING", 18 | "START": "2015-08-08T10:00:00", 19 | "END": "2015-08-08T13:00:00" 20 | }, 21 | { 22 | "ID": "4", 23 | "TYPE": "AFTERNOON", 24 | "START": "2015-08-08T14:00:00", 25 | "END": "2015-08-08T17:00:00" 26 | }, 27 | { 28 | "ID": "5", 29 | "TYPE": "MORNING", 30 | "START": "2015-08-09T10:00:00", 31 | "END": "2015-08-09T13:00:00" 32 | }, 33 | { 34 | "ID": "6", 35 | "TYPE": "AFTERNOON", 36 | "START": "2015-08-09T14:00:00", 37 | "END": "2015-08-09T17:00:00" 38 | }, 39 | { 40 | "ID": "7", 41 | "TYPE": "MORNING", 42 | "START": "2015-08-10T10:00:00", 43 | "END": "2015-08-10T13:00:00" 44 | }, 45 | { 46 | "ID": "8", 47 | "TYPE": "AFTERNOON", 48 | "START": "2015-08-10T14:00:00", 49 | "END": "2015-08-10T17:00:00" 50 | }, 51 | { 52 | "ID": "9", 53 | "TYPE": "MORNING", 54 | "START": "2015-08-11T10:00:00", 55 | "END": "2015-08-11T13:00:00" 56 | }, 57 | { 58 | "ID": "10", 59 | "TYPE": "AFTERNOON", 60 | "START": "2015-08-11T14:00:00", 61 | "END": "2015-08-11T17:00:00" 62 | }, 63 | { 64 | "ID": "11", 65 | "TYPE": "MORNING", 66 | "START": "2015-08-12T10:00:00", 67 | "END": "2015-08-12T13:00:00" 68 | } 69 | ] 70 | } 71 | -------------------------------------------------------------------------------- /stub/state.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | var path = require('path'), 2 | webpack = require('webpack'); 3 | 4 | var envPlugin = new webpack.DefinePlugin({ 5 | 'process.env': {NODE_ENV:'"production"'}, 6 | __DEV__: !process.env.BUILD_RELEASE 7 | }); 8 | 9 | var plugins = [envPlugin]; 10 | 11 | if(process.env.BUILD_RELEASE) { 12 | plugins.push(new webpack.optimize.DedupePlugin()); 13 | plugins.push(new webpack.optimize.UglifyJsPlugin([])); 14 | } else { 15 | plugins.push(new webpack.HotModuleReplacementPlugin()); 16 | plugins.push(new webpack.NoErrorsPlugin()); 17 | } 18 | 19 | module.exports = { 20 | entry: './src/index.js', 21 | output: { 22 | path: path.resolve(__dirname, 'build'), 23 | publicPath: '/', 24 | filename: 'bundle.js' 25 | }, 26 | module: { 27 | loaders: [ 28 | { 29 | test: /\.jsx?$/, 30 | exclude: /(node_modules)/, 31 | loaders: ['react-hot', 'babel'] 32 | } 33 | ] 34 | }, 35 | plugins: plugins 36 | }; 37 | --------------------------------------------------------------------------------