commit ea9336ec2cc753bc235f0513c9ac2ff433fbb875 Author: Titouan Rigoudy Date: Tue Mar 1 18:58:16 2016 +0100 Initial commit. diff --git a/.babelrc b/.babelrc new file mode 100644 index 0000000..82125b4 --- /dev/null +++ b/.babelrc @@ -0,0 +1,19 @@ +{ + "presets": ["es2015", "react", "stage-1"], + "env": { + "development": { + "plugins": [ + ["react-transform", { + "transforms": [{ + "transform": "react-transform-hmr", + "imports": ["react"], + "locals": ["module"] + }, { + "transform": "react-transform-catch-errors", + "imports": ["react", "redbox-react"] + }] + }] + ] + } + } +} diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..da0310f --- /dev/null +++ b/.editorconfig @@ -0,0 +1,13 @@ +# editorconfig.org +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.md] +trim_trailing_whitespace = false \ No newline at end of file diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000..b472a15 --- /dev/null +++ b/.eslintrc @@ -0,0 +1,65 @@ +{ + "parser": "babel-eslint", + "extends": "eslint:recommended", + "plugins": [ + "react" + ], + "env": { + "es6": true, + "browser": true, + "node": true, + "jquery": true, + "mocha": true + }, + "ecmaFeatures": { + "jsx": true, + "modules": true + }, + "rules": { + "quotes": 0, + "no-console": 1, + "no-debugger": 1, + "no-var": 1, + "semi": [1, "always"], + "no-trailing-spaces": 0, + "eol-last": 0, + "no-unused-vars": 0, + "no-underscore-dangle": 0, + "no-alert": 0, + "no-lone-blocks": 0, + "jsx-quotes": 1, + "react/display-name": [ 1, {"ignoreTranspilerName": false }], + "react/forbid-prop-types": [1, {"forbid": ["any"]}], + "react/jsx-boolean-value": 1, + "react/jsx-closing-bracket-location": 0, + "react/jsx-curly-spacing": 1, + "react/jsx-indent-props": 0, + "react/jsx-key": 1, + "react/jsx-max-props-per-line": 0, + "react/jsx-no-bind": 0, + "react/jsx-no-duplicate-props": 1, + "react/jsx-no-literals": 0, + "react/jsx-no-undef": 1, + "react/jsx-pascal-case": 1, + "react/jsx-sort-prop-types": 0, + "react/jsx-sort-props": 0, + "react/jsx-uses-react": 1, + "react/jsx-uses-vars": 1, + "react/no-danger": 1, + "react/no-did-mount-set-state": 1, + "react/no-did-update-set-state": 1, + "react/no-direct-mutation-state": 1, + "react/no-multi-comp": 1, + "react/no-set-state": 1, + "react/no-unknown-property": 1, + "react/prefer-es6-class": 1, + "react/prop-types": 1, + "react/react-in-jsx-scope": 1, + "react/require-extension": 1, + "react/self-closing-comp": 1, + "react/sort-comp": 1, + "react/wrap-multilines": 1 + }, + "globals": { + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3c69f3e --- /dev/null +++ b/.gitignore @@ -0,0 +1,36 @@ +# Logs +logs +*.log + +# Runtime data +pids +*.pid +*.seed + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (http://nodejs.org/api/addons.html) +build/Release + +# Dependency directory +# https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git +node_modules + +#dist folder +dist + +#Webstorm metadata +.idea + +# Mac files +.DS_Store diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..cffe8cd --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +save-exact=true diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..38f0967 --- /dev/null +++ b/LICENSE @@ -0,0 +1,22 @@ +The MIT License (MIT) + +Copyright (c) 2015 Cory House + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + diff --git a/README.md b/README.md new file mode 100644 index 0000000..d4ad24d --- /dev/null +++ b/README.md @@ -0,0 +1,176 @@ +# React Slingshot! + +React Slingshot is a comprehensive starter kit for rapid application development using React. It offers a rich development experience including: + +| **Tech** | **Description** |**Learn More**| +|----------|-------|---| +| [React](https://facebook.github.io/react/) | Fast, composable client-side components. | [Pluralsight Course](https://www.pluralsight.com/courses/react-flux-building-applications) | +| [Redux](http://redux.js.org) | Enforces unidirectional data flows and immutable, hot reloadable store. Supports time-travel debugging. Lean alternative to [Facebook's Flux](https://facebook.github.io/flux/docs/overview.html).| [Tutorial](https://egghead.io/series/getting-started-with-redux) | +| [Babel](http://babeljs.io) | Compiles ES6 to ES5. Enjoy the new version of JavaScript today. | [ES6 REPL](https://babeljs.io/repl/), [ES6 vs ES5](http://es6-features.org), [ES6 Katas](http://es6katas.org), [Pluralsight course](https://www.pluralsight.com/courses/javascript-fundamentals-es6) | +| [Webpack](http://webpack.github.io) | Bundles npm packages and our JS into a single file. Includes hot reloading via [react-transform-hmr](https://www.npmjs.com/package/react-transform-hmr). | [Quick Webpack How-to](https://github.com/petehunt/webpack-howto) [Pluralsight Course](https://www.pluralsight.com/courses/webpack-fundamentals)| +| [Browsersync](https://www.browsersync.io/) | Lightweight development HTTP server that supports synchronized testing and debugging on multiple devices. | [Intro vid](https://www.youtube.com/watch?time_continue=1&v=heNWfzc7ufQ)| +| [Mocha](http://mochajs.org) | Automated tests with [Chai](http://chaijs.com/) for assertions and [Cheerio](https://www.npmjs.com/package/cheerio) for DOM testing without a browser using Node. | [Pluralsight Course](https://www.pluralsight.com/courses/testing-javascript) | +| [TrackJS](https://trackjs.com/) | JavaScript error tracking. | [Free trial](https://my.trackjs.com/signup)| +| [ESLint](http://eslint.org/)| Lint JS. Reports syntax and style issues. Using [eslint-plugin-react](https://github.com/yannickcr/eslint-plugin-react) for additional React specific linting rules. | | +| [SASS](http://sass-lang.com/) | Compiled CSS styles with variables, functions, and more. | [Pluralsight Course](https://www.pluralsight.com/courses/better-css)| +| [Editor Config](http://editorconfig.org) | Enforce consistent editor settings (spaces vs tabs, etc). | [IDE Plugins](http://editorconfig.org/#download) | +| [npm Scripts](https://docs.npmjs.com/misc/scripts)| Glues all this together in a handy automated build. | [Pluralsight course](https://www.pluralsight.com/courses/npm-build-tool-introduction), [Why not Gulp?](https://medium.com/@housecor/why-i-left-gulp-and-grunt-for-npm-scripts-3d6853dd22b8#.vtaziro8n) | + +The starter kit includes a working example app that puts all of the above to use. + +## Get Started +1. **Initial Machine Setup**. First time running the starter kit? Then complete the [Initial Machine Setup](https://github.com/coryhouse/react-slingshot#initial-machine-setup). +2. **Clone the project**. `git clone https://github.com/coryhouse/react-slingshot.git`. +3. **Install Node packages**. `npm install` +4. **Run the example app**. `npm start -s` +This will run the automated build process, start up a webserver, and open the application in your default browser. When doing development with this kit, you'll want to keep the command line open at all times so that your code is rebuilt and tests run automatically every time you hit save. Note: The -s flag is optional. It enables silent mode which suppresses unnecessary messages during the build. +5. **Review the example app.** This starter kit includes a working example app that calculates fuel savings. Note how all source code is placed under /src. Tests are placed alongside the file under test. The final built app is placed under /dist. These are the files you run in production. +6. **Delete the example app files.** Once you're comfortable with how the example app works, you can [delete those files and begin creating your own app](https://github.com/coryhouse/react-slingshot#i-just-want-an-empty-starter-kit). + +##Initial Machine Setup +1. **Install [Node 4.0.0 or greater](https://nodejs.org)** +2. **Install [Git](https://git-scm.com/downloads)**. +3. **Install [React developer tools](https://chrome.google.com/webstore/detail/react-developer-tools/fmkadmapgofadopljbjfkapdkoienihi?hl=en) and [Redux Dev Tools](https://chrome.google.com/webstore/detail/redux-devtools/lmhkpmbekcpmknklioeibfkpmmfibljd?hl=en)** in Chrome. (Optional, but helpful. The latter offers time-travel debugging.) +4. On a Mac? You're all set. If you're on Linux or Windows, complete the steps for your OS below. + +**On Linux:** + + * Run this to [increase the limit](http://stackoverflow.com/questions/16748737/grunt-watch-error-waiting-fatal-error-watch-enospc) on the number of files Linux will watch. [Here's why](https://github.com/coryhouse/react-slingshot/issues/6). +`echo fs.inotify.max_user_watches=524288 | sudo tee -a /etc/sysctl.conf && sudo sysctl -p` + +**On Windows:** + + * **Install [Python 2.7](https://www.python.org/downloads/)**. Some node modules may rely on node-gyp, which requires Python on Windows. + * **Install C++ Compiler**. Browser-sync requires a C++ compiler on Windows. [Visual Studio Express](https://www.visualstudio.com/en-US/products/visual-studio-express-vs) comes bundled with a free C++ compiler. Or, if you already have Visual Studio installed: Open Visual Studio and go to File -> New -> Project -> Visual C++ -> Install Visual C++ Tools for Windows Desktop. The C++ compiler is used to compile browser-sync (and perhaps other Node modules). + +##FAQ +###Why does this exist? +This starter kit implements best practices like testing, minification, bundling, and so on. It codifies a long list of decisions that you no longer have to make to get rolling. It saves you from the long, painful process of wiring it all together into an automated dev environment and build process. It's also useful as inspiration for ideas you might want to integrate into your current development environment or build process. + +###What do the scripts in package.json do? +Unfortunately, scripts in package.json can't be commented inline because the JSON spec doesn't support comments, so I'm providing info on what each script in package.json does here. + +| **Script** | **Description** | +|----------|-------| +| prestart | Runs automatically before start. Calls remove-dist script which deletes the dist folder. This helps remind you to run the build script before committing since the dist folder will be deleted if you don't. ;) | +| start | Runs tests, lints, starts dev webserver, and opens the app in your default browser. | +| lint:tools | Runs ESLint on build related JS files. (eslint-loader lints src files via webpack when `npm start` is run) | +| clean-dist | Removes everything from the dist folder. | +| remove-dist | Deletes the dist folder. | +| create-dist | Creates the dist folder and the necessary subfolders. | +| build:html | Adds trackJS tracking script and copies to /dist. | +| prebuild | Runs automatically before build script (due to naming convention). Cleans dist folder, builds html, and builds sass. | +| build | Bundles all JavaScript using webpack and writes it to /dist. | +| test | Runs tests (files ending in .spec.js) using Mocha and outputs results to the command line. Watches all files so tests are re-run upon save. | + +###Can you explain the file structure? +``` +. +├── .babelrc # Configures Babel +├── .editorconfig # Configures editor rules +├── .eslintrc # Configures ESLint +├── .gitignore # Tells git which files to ignore +├── README.md # This file. +├── dist # Folder where the build script places the built app. Use this in prod. +├── package.json # Package configuration. The list of 3rd party libraries and utilities +├── src # Source code +│   ├── actions # Flux/Redux actions. List of distinct actions that can occur in the app. +│   ├── businessLogic # Plain old JS objects (POJOs). Pure logic. No framework specific code here. +│   ├── components # React components +│   ├── constants # Application constants including constants for Redux +│   ├── containers # App container for Redux +│   ├── favicon.ico # favicon to keep your browser from throwing a 404 during dev. Not actually used in prod build. +│   ├── index.html # Start page +│   ├── index.js # Entry point for your app +│   ├── reducers # Redux reducers. Your state is altered here based on actions +│   ├── store # Redux store configuration +│   └── styles # CSS Styles, typically written in Sass +├── tools # Node scripts that run build related tools +│   ├── build.js # Runs the production build +│   ├── buildHtml.js # Builds index.html +│   ├── distServer.js # Starts webserver and opens final built app that's in dist in your default browser +│   ├── srcServer.js # Starts dev webserver with hot reloading and opens your app in your default browser +└── webpack.config.js # Configures webpack +``` + + +###Where are the files being served from when I run `npm start`? +Webpack serves your app in memory when you run `npm start`. No physical files are written. However, the web root is /src, so you can reference files under /src in index.html. When the app is built using `npm run build`, physical files are written to /dist and the app is served from /dist. + +###How is Sass being converted into CSS and landing in the browser? +Magic! Okay, more specifically, we're handling it differently in dev (`npm start`) vs prod (`npm run build`) + +When you run `npm start`: + + 1. The sass-loader compiles Sass into CSS + 2. Webpack bundles the compiled CSS into bundle.js. Sounds odd, but it works! + 3. bundle.js contains code that loads styles into the <head> of index.html via JavaScript. This is why you don't see a stylesheet reference in index.html. In fact, if you disable JavaScript in your browser, you'll see the styles don't load either. + +The approach above supports hot reloading, which is great for development. However, it also create a flash of unstyled content on load because you have to wait for the JavaScript to parse and load styles before they're applied. So for the production build, we use a different approach: + +When you run `npm run build`: + + 1. The sass-loader compiles Sass into CSS + 2. The [extract-text-webpack-plugin](https://github.com/webpack/extract-text-webpack-plugin) extracts the compiled Sass into styles.css + 3. buildHtml.js adds a reference to the stylesheet to the head of index.html. + +For both of the above methods, a separate sourcemap is generated for debugging Sass in [compatible browsers](http://thesassway.com/intermediate/using-source-maps-with-sass). + +###I don't like the magic you just described above. I simply want to use a CSS file. +No problem. Reference your CSS file in index.html, and add a step to the build process to copy your CSS file over to the same relative location /dist as part of the build step. But be forwarned, you lose style hot reloading with this approach. + +### I just want an empty starter kit. +This starter kit includes an example app so you can see how everything hangs together on a real app. To create an empty project, you can delete the following: + + 1. Components in src/components + 2. Styles in src/styles/styles.scss + 3. Delete files in src/businessLogic + +Don't want to use Redux? See the next question for some steps on removing Redux. + +### Do I have to use Redux? +Nope. Redux is useful for applications with more complex data flows. If your app is simple, Redux is overkill. Remove Redux like this: + + 1. Delete the following folders and their contents: actions, constants, reducers, containers, store + 2. Uninstall Redux related packages: `npm uninstall redux react-redux redux-thunk` + 3. Remove Redux related imports from /src/index.js: `import configureStore from './store/configureStore';`, `import App from './containers/App';` and `import { Provider } from 'react-redux';` + 4. Remove this line from /src/index.js: `const store = configureStore();` + 5. Delete components in /components and create a new empty component. + 6. Replace the call to `` in /src/index.js with a call to the new top level component you just created in step 5. + +### How do I deploy this? +`npm run build`. This will build the project for production. It does the following: +* Minifies all JS +* Sets NODE_ENV to prod so that React is built in production mode +* Places the resulting built project files into /dist. (This is the folder you'll expose to the world). + +### Why are test files placed alongside the file under test (instead of centralized)? +Streamlined automated testing is a core feature of this starter kit. All tests are placed in files that end in .spec.js. Spec files are placed in the same directory as the file under test. Why? ++ The existence of tests is highly visible. If a corresponding .spec file hasn't been created, it's obvious. ++ Easy to open since they're in the same folder as the file being tested. ++ Easy to create new test files when creating new source files. ++ Short import paths are easy to type and less brittle. ++ As files are moved, it's easy to move tests alongside. + +That said, you can of course place your tests under /test instead, which is the Mocha default. If you do, you can simplify the test script to no longer specify the path. Then Mocha will simply look in /test to find your spec files. + +### How do I debug? +Since browsers don't currently support ES6, we're using Babel to compile our ES6 down to ES5. This means the code that runs in the browser looks different than what we wrote. But good news, a [sourcemap](http://www.html5rocks.com/en/tutorials/developertools/sourcemaps/) is generated to enable easy debugging. This means your original JS source will be displayed in your browser's dev console. +*Note:* When you run `npm start`, no JS is minified. Why? Because minifying slows the build. So JS is only minified when you run the `npm run build` script. See [more on building for production below](https://github.com/coryhouse/react-slingshot#how-do-i-deploy-this). + +Also note that no actual physical files are written to the filesystem during the dev build. **For performance, all files exist in memory when served from the webpack server.**. Physical files are only written when you run `npm run build`. + +**Tips for debugging via sourcemaps:** + + 1. Browsers vary in the way they allow you to view the original source. Chrome automatically shows the original source if a sourcemap is available. Safari, in contrast, will display the minified source and you'll [have to cmd+click on a given line to be taken to the original source](http://stackoverflow.com/questions/19550060/how-do-i-toggle-source-mapping-in-safari-7). + 2. Do **not** enable serving files from your filesystem in Chrome dev tools. If you do, Chrome (and perhaps other browsers) may not show you the latest version of your code after you make a source code change. Instead **you must close the source view tab you were using and reopen it to see the updated source code**. It appears Chrome clings to the old sourcemap until you close and reopen the source view tab. To clarify, you don't have to close the actual tab that is displaying the app, just the tab in the console that's displaying the source file that you just changed. + 3. If the latest source isn't displaying the console, force a refresh. Sometimes Chrome seems to hold onto a previous version of the sourcemap which will cause you to see stale code. + +### I'm getting an error when running npm install: Failed to locate "CL.exe" +On Windows, you need to install extra dependencies for browser-sync to build and install successfully. Follow the getting started steps above to assure you have the necessary dependencies on your machine. + +### I can't access the external URL for Browsersync +To hit the external URL, all devices must be on the same LAN. So this may mean your dev machine needs to be on the same Wifi as the mobile devices you're testing. + +###What about the Redux Devtools? +They're not included at this time to keep the project simple. If you're interested, Barry Staes created a [branch with the devtools incorporated](https://github.com/coryhouse/react-slingshot/pull/27). diff --git a/favicon.ico b/favicon.ico new file mode 100644 index 0000000..6848441 Binary files /dev/null and b/favicon.ico differ diff --git a/package.json b/package.json new file mode 100644 index 0000000..49d7820 --- /dev/null +++ b/package.json @@ -0,0 +1,82 @@ +{ + "name": "react-slingshot", + "version": "1.2.0", + "description": "Starter kit for creating apps with React and Redux", + "scripts": { + "prestart": "npm run remove-dist", + "start": "parallelshell \"npm run lint:tools\" \"npm run test:watch\" \"npm run open:src\"", + "open:src": "babel-node tools/srcServer.js", + "open:dist": "babel-node tools/distServer.js", + "lint:tools": "eslint webpack.config.js tools", + "clean-dist": "npm run remove-dist && mkdir dist", + "remove-dist": "node_modules/.bin/rimraf ./dist", + "build:html": "babel-node tools/buildHtml.js", + "prebuild": "npm run clean-dist && npm run build:html", + "build": "npm run test && babel-node tools/build.js && npm run open:dist", + "test": "cross-env NODE_ENV=test mocha --reporter progress --compilers js:babel-core/register --recursive \"./src/**/*.spec.js\"", + "test:watch": "npm run test -- --watch" + }, + "author": "Cory House", + "license": "MIT", + "dependencies": { + "object-assign": "4.0.1", + "react": "0.14.7", + "react-dom": "0.14.7", + "react-redux": "4.4.0", + "redux": "3.3.1" + }, + "devDependencies": { + "babel-cli": "6.5.1", + "babel-core": "6.5.1", + "babel-eslint": "5.0.0", + "babel-loader": "6.2.3", + "babel-plugin-react-display-name": "2.0.0", + "babel-plugin-react-transform": "2.0.0", + "babel-preset-es2015": "6.5.0", + "babel-preset-react": "6.5.0", + "babel-preset-stage-1": "6.5.0", + "browser-sync": "2.11.1", + "chai": "3.4.1", + "cheerio": "0.19.0", + "colors": "1.1.2", + "cross-env": "1.0.7", + "css-loader": "0.23.1", + "eslint": "2.2.0", + "eslint-loader": "1.3.0", + "eslint-plugin-react": "4.0.0", + "extract-text-webpack-plugin": "1.0.1", + "file-loader": "0.8.5", + "mocha": "2.3.4", + "node-sass": "3.4.2", + "parallelshell": "2.0.0", + "react-transform-catch-errors": "1.0.1", + "react-transform-hmr": "1.0.1", + "redbox-react": "1.2.0", + "rimraf": "2.5.0", + "sass-loader": "3.1.2", + "style-loader": "0.13.0", + "watch": "0.17.1", + "webpack": "1.12.11", + "webpack-dev-middleware": "1.4.0", + "webpack-hot-middleware": "2.6.0", + "yargs": "3.32.0" + }, + "keywords:": [ + "react", + "reactjs", + "hot", + "reload", + "hmr", + "live", + "edit", + "webpack", + "redux", + "flux", + "boilerplate", + "starter" + ], + "repository": { + "type": "git", + "url": "https://github.com/coryhouse/react-slingshot" + } +} diff --git a/src/actions/fuelSavingsActions.js b/src/actions/fuelSavingsActions.js new file mode 100644 index 0000000..9401470 --- /dev/null +++ b/src/actions/fuelSavingsActions.js @@ -0,0 +1,9 @@ +import * as types from '../constants/ActionTypes'; + +export function saveFuelSavings(settings) { + return { type: types.SAVE_FUEL_SAVINGS, settings }; +} + +export function calculateFuelSavings(settings, fieldName, value) { + return { type: types.CALCULATE_FUEL_SAVINGS, settings, fieldName, value }; +} \ No newline at end of file diff --git a/src/businessLogic/dateHelper.js b/src/businessLogic/dateHelper.js new file mode 100644 index 0000000..b5e1f19 --- /dev/null +++ b/src/businessLogic/dateHelper.js @@ -0,0 +1,10 @@ +export default class DateHelper { + //See tests for desired format. + static getFormattedDateTime(date) { + return `${date.getMonth() + 1}/${date.getDate()} ${date.getHours()}:${this.padLeadingZero(date.getMinutes())}:${this.padLeadingZero(date.getSeconds())}`; + } + + static padLeadingZero(value) { + return value > 9 ? value : '0' + value; + } +} diff --git a/src/businessLogic/dateHelper.spec.js b/src/businessLogic/dateHelper.spec.js new file mode 100644 index 0000000..e818288 --- /dev/null +++ b/src/businessLogic/dateHelper.spec.js @@ -0,0 +1,26 @@ +import chai from 'chai'; +import DateHelper from './dateHelper'; + +chai.should(); + +describe('Date Helper', () => { + describe('getFormattedDateTime', () => { + it('returns mm/dd hh:mm:ss formatted time when passed a date', () => { + //arrange + //The 7 numbers specify the year, month, day, hour, minute, second, and millisecond, in that order + let date = new Date(99,0,24,11,33,30,0); + + //assert + DateHelper.getFormattedDateTime(date).should.equal('1/24 11:33:30'); + }); + + it('pads single digit minute and second values with leading zeros', () => { + //arrange + //The 7 numbers specify the year, month, day, hour, minute, second, and millisecond, in that order + let date = new Date(99,0,4,11,3,2,0); + + //assert + DateHelper.getFormattedDateTime(date).should.equal('1/4 11:03:02'); + }); + }); +}); diff --git a/src/businessLogic/fuelSavingsCalculator.js b/src/businessLogic/fuelSavingsCalculator.js new file mode 100644 index 0000000..643a6d3 --- /dev/null +++ b/src/businessLogic/fuelSavingsCalculator.js @@ -0,0 +1,67 @@ +import mathHelper from './mathHelper'; +import NumberFormatter from './numberFormatter'; + +//This file uses the factory function pattern instead of a class. +//Just showing an alternative to using a class. +//This declares a function with a private method. +//The public function returns an object literal. +//Could arguably be called FuelSavingCalculatorFactory. +let fuelSavingsCalculator = function() { + //private + let calculateMonthlyCost = function(milesDrivenPerMonth, ppg, mpg) { + let gallonsUsedPerMonth = milesDrivenPerMonth / mpg; + return gallonsUsedPerMonth * ppg; + }; + + //public + return { + calculateMilesDrivenPerMonth: function(milesDriven, milesDrivenTimeframe) { + const monthsPerYear = 12; + const weeksPerYear = 52; + + switch (milesDrivenTimeframe) { + case 'week': + return (milesDriven * weeksPerYear) / monthsPerYear; + case 'month': + return milesDriven; + case 'year': + return milesDriven / monthsPerYear; + default: + throw 'Unknown milesDrivenTimeframe passed: ' + milesDrivenTimeframe; + } + }, + + calculateSavingsPerMonth: function(settings) { + if (!settings.milesDriven) { + return 0; + } + + let milesDrivenPerMonth = this.calculateMilesDrivenPerMonth(settings.milesDriven, settings.milesDrivenTimeframe); + let tradeFuelCostPerMonth = calculateMonthlyCost(milesDrivenPerMonth, settings.tradePpg, settings.tradeMpg); + let newFuelCostPerMonth = calculateMonthlyCost(milesDrivenPerMonth, settings.newPpg, settings.newMpg); + let savingsPerMonth = tradeFuelCostPerMonth - newFuelCostPerMonth; + + return mathHelper.roundNumber(savingsPerMonth, 2); + }, + + + necessaryDataIsProvidedToCalculateSavings: function(settings) { + return settings.newMpg > 0 + && settings.tradeMpg > 0 + && settings.newPpg > 0 + && settings.tradePpg > 0 + && settings.milesDriven > 0; + }, + + calculateSavings: function(settings) { + let monthlySavings = this.calculateSavingsPerMonth(settings); + return { + monthly: NumberFormatter.getCurrencyFormattedNumber(monthlySavings), + annual: NumberFormatter.getCurrencyFormattedNumber(monthlySavings * 12), + threeYear: NumberFormatter.getCurrencyFormattedNumber(monthlySavings * 12 * 3) + }; + } + }; +}; + +export default fuelSavingsCalculator; diff --git a/src/businessLogic/fuelSavingsCalculator.spec.js b/src/businessLogic/fuelSavingsCalculator.spec.js new file mode 100644 index 0000000..f86a13b --- /dev/null +++ b/src/businessLogic/fuelSavingsCalculator.spec.js @@ -0,0 +1,123 @@ +import chai from 'chai'; +import Calculator from './fuelSavingsCalculator'; + +chai.should(); + +describe('Fuel Savings Calculator', () => { + describe('necessaryDataIsProvidedToCalculateSavings', () => { + it('returns false when necessary data isn\'t provided', () => { + //arrange + let settings = { + newMpg: 20 + }; + + //assert + Calculator().necessaryDataIsProvidedToCalculateSavings(settings).should.equal(false); + }); + + it('returns true when necessary data is provided', () => { + //arrange + let settings = { + newMpg: 20, + tradeMpg: 10, + newPpg: 1.50, + tradePpg: 1.50, + milesDriven: 100 + }; + + //assert + Calculator().necessaryDataIsProvidedToCalculateSavings(settings).should.equal(true); + }); + }); + + describe("milesPerMonth", () => { + it("converts a weekly timeframe to a monthly timeframe", () => { + //arrange + var milesPerWeek = 100; + + //act + var milesPerMonth = Calculator().calculateMilesDrivenPerMonth(milesPerWeek, 'week'); + + //assert + milesPerMonth.should.equal(433.3333333333333); + }); + + it("returns a monthly timeframe untouched", () => { + //arrange + var milesPerMonth = 300; + + //act + var milesPerMonthCalculated = Calculator().calculateMilesDrivenPerMonth(milesPerMonth, 'month'); + + //assert + milesPerMonthCalculated.should.equal(milesPerMonth); + }); + + it("converts a yearly timeframe to a monthly timeframe", () => { + //arrange + var milesPerYear = 1200; + + //act + var milesPerMonth = Calculator().calculateMilesDrivenPerMonth(milesPerYear, 'year'); + + //assert + milesPerMonth.should.equal(100); + }); + }); + + describe("calculateSavingsPerMonth", () => { + it("returns 29.93 in savings per month with these settings", () => { + //arrange + var settings = { + tradePpg: 3.75, + tradeMpg: 24, + newPpg: 3.75, + newMpg: 38, + milesDriven: 120, + milesDrivenTimeframe: 'week' + }; + + //act + var savingsPerMonth = Calculator().calculateSavingsPerMonth(settings); + + //assert + savingsPerMonth.should.equal(29.93); + }); + + it("returns 40.83 in savings per month with these settings", () => { + //arrange + var settings = { + tradePpg: 4.15, + tradeMpg: 24, + newPpg: 3.75, + newMpg: 38, + milesDriven: 550, + milesDrivenTimeframe: 'month' + }; + + //act + var savingsPerMonth = Calculator().calculateSavingsPerMonth(settings); + + //assert + savingsPerMonth.should.equal(40.83); + }); + + it("returns -157.12 in loss per month with these settings", () => { + //arrange + var settings = { + tradePpg: 3.15, + tradeMpg: 40, + newPpg: 3.75, + newMpg: 18, + milesDriven: 14550, + milesDrivenTimeframe: 'year' + }; + + //act + var savingsPerMonth = Calculator().calculateSavingsPerMonth(settings); + + //assert + savingsPerMonth.should.equal(-157.12); + }); + }); +}); \ No newline at end of file diff --git a/src/businessLogic/mathHelper.js b/src/businessLogic/mathHelper.js new file mode 100644 index 0000000..52b7c69 --- /dev/null +++ b/src/businessLogic/mathHelper.js @@ -0,0 +1,40 @@ +class MathHelper { + static roundNumber(numberToRound, numberOfDecimalPlaces) { + if (numberToRound === 0) { + return 0; + } + + if (!numberToRound) { + return ''; + } + + const scrubbedNumber = numberToRound.toString().replace('$', '').replace(',', ''); + return Math.round(scrubbedNumber * Math.pow(10, numberOfDecimalPlaces)) / Math.pow(10, numberOfDecimalPlaces); + } + + static addArray(values) { //adds array of values passed. + if (values == null) { + return null; + } + + let total = 0; + for (let i in values) { + total += parseInt(this.convertToPennies(values[i])); //do math in pennies to assure accuracy. + } + + return total / 100; //convert back into dollars + } + + static convertToPennies(dollarValue) { + if (dollarValue === 0) { + return 0; + } + + dollarValue = parseFloat(dollarValue); + dollarValue = this.roundNumber(dollarValue, 2); //round to 2 decimal places. + const dollarValueContainsDecimal = (dollarValue.toString().indexOf(".") !== -1); + return (dollarValueContainsDecimal) ? parseInt(dollarValue.toString().replace('.', '')) : parseInt(dollarValue) * 100; + } +} + +export default MathHelper; diff --git a/src/businessLogic/mathHelper.spec.js b/src/businessLogic/mathHelper.spec.js new file mode 100644 index 0000000..c567a21 --- /dev/null +++ b/src/businessLogic/mathHelper.spec.js @@ -0,0 +1,60 @@ +import chai from 'chai'; +import MathHelper from './mathHelper'; + +chai.should(); + +describe('Math Helper', () => { + describe('roundNumber', () => { + it('returns 0 when passed null', () => { + MathHelper.roundNumber(null).should.equal(''); + }); + + it('returns 0 when passed 0', () => { + MathHelper.roundNumber(0).should.equal(0); + }); + + it('rounds up to 1.56 when passed 1.55555 rounded to 2 digits', () => { + MathHelper.roundNumber(1.55555, 2).should.equal(1.56); + }); + + it('rounds up to -1.56 when passed -1.55555 rounded to 2 digits', () => { + MathHelper.roundNumber(-1.55555, 2).should.equal(-1.56); + }); + }); + + describe('addArray', () => { + it('returns 0 when passed empty array', () => { + MathHelper.addArray([]).should.equal(0); + }); + + // it('returns null when passed null', () => { + // should.not.exist(MathHelper.addArray(null)); + // }); + + it('returns 6 when passed [2,4]', () => { + MathHelper.addArray([2,4]).should.equal(6); + }); + + it('returns 7 when passed [-6, 11, 2]', () => { + MathHelper.addArray([-6, 11, 2]).should.equal(7); + }); + }); + + describe('convertToPennies', () => { + it('returns 142 when passed 1.42', () => { + MathHelper.convertToPennies(1.42).should.equal(142); + }); + + it('returns 0 when passed 0', () => { + MathHelper.convertToPennies(0).should.equal(0); + }); + + it('rounds down to 132 when passed 1.3242', () => { + MathHelper.convertToPennies(1.3242).should.equal(132); + }); + + it('rounds up to 133 when passed 1.325', () => { + MathHelper.convertToPennies(1.325).should.equal(133); + }); + }); +}); diff --git a/src/businessLogic/numberFormatter.js b/src/businessLogic/numberFormatter.js new file mode 100644 index 0000000..ddd73a8 --- /dev/null +++ b/src/businessLogic/numberFormatter.js @@ -0,0 +1,58 @@ +import MathHelper from './mathHelper'; + +class NumberFormatter { + static getCurrencyFormattedNumber(value) { + if (value === null) { + return ''; + } + + value = this.getFormattedNumber(value); + return '$' + value; + } + + static getFormattedNumber(value) { + if (value === 0) { + return 0; + } + + if (!value) { + return ''; + } + + if (!this.isInt(this.scrubFormatting(value))) { + return ''; //if it's not a number after scrubbing formatting, just return empty. + } + + let roundedValue = MathHelper.roundNumber(value, 2); //round if more than 2 decimal points + roundedValue = roundedValue.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ","); //add commas for 1,000's. RegEx from http://stackoverflow.com/questions/2901102/how-to-print-a-number-with-commas-as-thousands-separators-in-javascript + const roundedValueContainsDecimalPlace = (roundedValue.indexOf('.') !== -1); + + if (roundedValueContainsDecimalPlace) { + const numbersToTheRightOfDecimal = roundedValue.split('.')[1]; + + switch (numbersToTheRightOfDecimal.length) { + case 0: + return roundedValue.replace('.', ''); //no decimal necessary since no numbers after decimal + case 1: + return roundedValue + '0'; + default: + return roundedValue; + } + } + return roundedValue; + } + + static isInt(n) { + if (n === '' || n === null) { + return false; + } + + return n % 1 === 0; + } + + static scrubFormatting(value) { + return value.toString().replace('$', '').replace(',', '').replace('.', ''); + } +} + +export default NumberFormatter; diff --git a/src/businessLogic/numberFormatter.spec.js b/src/businessLogic/numberFormatter.spec.js new file mode 100644 index 0000000..aca61e4 --- /dev/null +++ b/src/businessLogic/numberFormatter.spec.js @@ -0,0 +1,38 @@ +import NumberFormatter from './numberFormatter'; +import chai from 'chai'; + +chai.should(); + +describe('Number Formatter', () => { + describe('getCurrencyFormattedNumber', () => { + it('returns $5.50 when passed 5.5', () => { + NumberFormatter.getCurrencyFormattedNumber(5.5).should.equal("$5.50"); + }); + }); + + describe('isInt', () => { + it('returns true when passed 0', () => { + NumberFormatter.isInt(0).should.equal(true); + }); + + it('returns false when passed an empty string', () => { + NumberFormatter.isInt('').should.equal(false); + }); + + it('returns true when passed int as a string', () => { + NumberFormatter.isInt('5').should.equal(true); + }); + }); + + describe('scrubFormatting', () => { + it('strips commas and decimals', () => { + NumberFormatter.scrubFormatting('1,234.56').should.equal('123456'); + }); + }); + + describe('getFormattedNumber', () => { + it('returns 0 if passed 0', () => { + NumberFormatter.getFormattedNumber(0).should.equal(0); + }); + }); +}); diff --git a/src/components/FuelSavingsApp.js b/src/components/FuelSavingsApp.js new file mode 100644 index 0000000..069d65a --- /dev/null +++ b/src/components/FuelSavingsApp.js @@ -0,0 +1,72 @@ +import React, {PropTypes} from 'react'; +import FuelSavingsResults from './FuelSavingsResults'; +import FuelSavingsTextInput from './FuelSavingsTextInput'; + +const FuelSavingsApp = (props) => { + const save = function () { + props.actions.saveFuelSavings(props.appState); + }; + + const onTimeframeChange = function (e) { + props.actions.calculateFuelSavings(props, 'milesDrivenTimeframe', e.target.value); + }; + + const fuelSavingsKeypress = function (name, value) { + props.actions.calculateFuelSavings(props, name, value); + }; + + const settings = props.appState; + + return ( +
+

Fuel Savings Analysis

+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ miles per + +
{settings.dateModified}
+ +
+ + {settings.necessaryDataIsProvidedToCalculateSavings ? : null} + +
+ ); +}; + +FuelSavingsApp.propTypes = { + actions: PropTypes.object.isRequired, + appState: PropTypes.object.isRequired +}; + +export default FuelSavingsApp; diff --git a/src/components/FuelSavingsApp.spec.js b/src/components/FuelSavingsApp.spec.js new file mode 100644 index 0000000..f3dcc12 --- /dev/null +++ b/src/components/FuelSavingsApp.spec.js @@ -0,0 +1,8 @@ +import chai from 'chai'; +import FuelSavingsCalculatorForm from './FuelSavingsApp'; + +chai.should(); + +describe('Fuel Savings Calculator Component', () => { + +}); diff --git a/src/components/FuelSavingsResults.js b/src/components/FuelSavingsResults.js new file mode 100644 index 0000000..1042ebb --- /dev/null +++ b/src/components/FuelSavingsResults.js @@ -0,0 +1,49 @@ +import React, {PropTypes} from 'react'; +import NumberFormatter from '../businessLogic/numberFormatter'; + +//This is a stateless functional component. (Also known as pure or dumb component) +//More info: https://facebook.github.io/react/blog/2015/10/07/react-v0.14.html#stateless-functional-components +//And https://medium.com/@joshblack/stateless-components-in-react-0-14-f9798f8b992d +//And starter kit with more examples here: https://github.com/ericelliott/react-pure-component-starter +const FuelSavingsResults = (props) => { + const savingsExist = NumberFormatter.scrubFormatting(props.savings.monthly) > 0; + const savingsClass = savingsExist ? 'savings' : 'loss'; + const resultLabel = savingsExist ? 'Savings' : 'Loss'; + + //You can even exclude the return statement below if the entire component is + //composed within the parentheses. Return is necessary here because some + //variables are set above. + return ( + + + + + + + +
{resultLabel} + + + + + + + + + + + + + +
Monthly1 Year3 Year
{props.savings.monthly}{props.savings.annual}{props.savings.threeYear}
+
+ ); +}; + +//Note that this odd style is utilized for propType validation for now. Must be defined *after* +//the component is defined, which is why it's separate and down here. +FuelSavingsResults.propTypes = { + savings: PropTypes.object.isRequired +}; + +export default FuelSavingsResults; diff --git a/src/components/FuelSavingsResults.spec.js b/src/components/FuelSavingsResults.spec.js new file mode 100644 index 0000000..52803ef --- /dev/null +++ b/src/components/FuelSavingsResults.spec.js @@ -0,0 +1,63 @@ +import chai from 'chai'; +import cheerio from 'cheerio'; +import FuelSavingsResults from './FuelSavingsResults'; +import React from 'react'; +import ReactDOMServer from 'react/lib/ReactDOMServer'; + +chai.should(); + +/*This test file displays how to test a React component's HTML + outside of the browser. It uses Cheerio, which is a handy + server-side library that mimics jQuery. So to test a React + components HTML for a given state we do the following: + 1. Instantiate the component and pass the desired prop values + 2. Use ReactDOMServer to generate the resulting HTML + 3. Use Cheerio to load the HTML into a fake DOM + 4. Use Cheerio to query the DOM using jQuery style selectors + 5. Assert that certain DOM elements exist with expected values. + */ +describe('Fuel Savings Calculator Results Component', () => { + describe('Savings label', () => { + it('displays as savings when savings exist', () => { + //arrange + var props = { + savings: { + monthly: '10', + annual: '120', + threeYear: '360' + } + }; + + var sut = React.createElement(FuelSavingsResults, props); + + //act + var html = ReactDOMServer.renderToStaticMarkup(sut); + let $ = cheerio.load(html); + var fuelSavingsLabel = $('.fuel-savings-label').html(); + + //assert + fuelSavingsLabel.should.equal('Savings'); + }); + + it('display as loss when savings don\'t exist', () => { + //arrange + var props = { + savings: { + monthly: '-10', + annual: '-120', + threeYear: '-360' + } + }; + + var sut = React.createElement(FuelSavingsResults, props); + + //act + var html = ReactDOMServer.renderToStaticMarkup(sut); + let $ = cheerio.load(html); + var fuelSavingsLabel = $('.fuel-savings-label').html(); + + //assert + fuelSavingsLabel.should.equal('Loss'); + }); + }); +}); diff --git a/src/components/FuelSavingsTextInput.js b/src/components/FuelSavingsTextInput.js new file mode 100644 index 0000000..696916d --- /dev/null +++ b/src/components/FuelSavingsTextInput.js @@ -0,0 +1,31 @@ +import React, { Component, PropTypes } from 'react'; + +function buildHandleChange(props) { + return function handleChange(e) { + props.onChange(props.name, e.target.value); + }; +} + +const FuelSavingsTextInput = (props) => { + const handleChange = buildHandleChange(props); + + return ( + + ); +}; + +FuelSavingsTextInput.propTypes = { + name: PropTypes.string.isRequired, + onChange: PropTypes.func.isRequired, + placeholder: PropTypes.string, + value: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.number + ]) +}; + +export default FuelSavingsTextInput; diff --git a/src/constants/ActionTypes.js b/src/constants/ActionTypes.js new file mode 100644 index 0000000..55abcf1 --- /dev/null +++ b/src/constants/ActionTypes.js @@ -0,0 +1,2 @@ +export const SAVE_FUEL_SAVINGS = 'SAVE_FUEL_SAVINGS'; +export const CALCULATE_FUEL_SAVINGS = 'CALCULATE_FUEL_SAVINGS'; \ No newline at end of file diff --git a/src/containers/App.js b/src/containers/App.js new file mode 100644 index 0000000..5ac4831 --- /dev/null +++ b/src/containers/App.js @@ -0,0 +1,37 @@ +// This file bootstraps the app with the boilerplate necessary +// to support hot reloading in Redux +import React, {PropTypes} from 'react'; +import { bindActionCreators } from 'redux'; +import { connect } from 'react-redux'; +import FuelSavingsApp from '../components/FuelSavingsApp'; +import * as actions from '../actions/fuelSavingsActions'; + +class App extends React.Component { + render() { + return ( + + ); + } +} + +App.propTypes = { + actions: PropTypes.object.isRequired, + appState: PropTypes.object.isRequired +}; + +function mapStateToProps(state) { + return { + appState: state.fuelSavingsAppState + }; +} + +function mapDispatchToProps(dispatch) { + return { + actions: bindActionCreators(actions, dispatch) + }; +} + +export default connect( + mapStateToProps, + mapDispatchToProps +)(App); diff --git a/src/index.html b/src/index.html new file mode 100644 index 0000000..d1eb595 --- /dev/null +++ b/src/index.html @@ -0,0 +1,11 @@ + + + + + React Slingshot + + +
+ + + diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..bc95648 --- /dev/null +++ b/src/index.js @@ -0,0 +1,14 @@ +import React from 'react'; +import {render} from 'react-dom'; +import { Provider } from 'react-redux'; +import App from './containers/App'; +import configureStore from './store/configureStore'; +import './styles/styles.scss'; //Yep, that's right. You can import SASS/CSS files too! Webpack will run the associated loader and plug this into the page. + +const store = configureStore(); + +render( + + + , document.getElementById('app') +); diff --git a/src/reducers/fuelSavings.js b/src/reducers/fuelSavings.js new file mode 100644 index 0000000..d15d5a6 --- /dev/null +++ b/src/reducers/fuelSavings.js @@ -0,0 +1,53 @@ +import {SAVE_FUEL_SAVINGS, CALCULATE_FUEL_SAVINGS} from '../constants/ActionTypes'; +import calculator from '../businessLogic/fuelSavingsCalculator'; +import dateHelper from '../businessLogic/dateHelper'; +import objectAssign from 'object-assign'; + +const initialState = { + newMpg: null, + tradeMpg: null, + newPpg: null, + tradePpg: null, + milesDriven: null, + milesDrivenTimeframe: 'week', + displayResults: false, + dateModified: null, + necessaryDataIsProvidedToCalculateSavings: false, + savings: { + monthly: 0, + annual: 0, + threeYear: 0 + } +}; + +//IMPORTANT: Note that with Redux, state should NEVER be changed. +//State is considered immutable. Instead, +//create a copy of the state passed and set new values on the copy. +//Note that I'm using Object.assign to create a copy of current state +//and update values on the copy. +export default function fuelSavingsAppState(state = initialState, action) { + switch (action.type) { + case SAVE_FUEL_SAVINGS: + // For this example, just simulating a save by changing date modified. + // In a real app using Redux, you might use redux-thunk and handle the async call in fuelSavingsActions.js + return objectAssign({}, state, { dateModified: dateHelper.getFormattedDateTime(new Date()) }); + + case CALCULATE_FUEL_SAVINGS: + { // limit scope with this code block, to satisfy eslint no-case-declarations rule. + let newState = objectAssign({}, state); + newState[action.fieldName] = action.value; + let calc = calculator(); + newState.necessaryDataIsProvidedToCalculateSavings = calc.necessaryDataIsProvidedToCalculateSavings(newState); + newState.dateModified = dateHelper.getFormattedDateTime(new Date()); + + if (newState.necessaryDataIsProvidedToCalculateSavings) { + newState.savings = calc.calculateSavings(newState); + } + + return newState; + } + + default: + return state; + } +} diff --git a/src/reducers/index.js b/src/reducers/index.js new file mode 100644 index 0000000..cecd810 --- /dev/null +++ b/src/reducers/index.js @@ -0,0 +1,8 @@ +import { combineReducers } from 'redux'; +import fuelSavingsAppState from './fuelSavings'; + +const rootReducer = combineReducers({ + fuelSavingsAppState +}); + +export default rootReducer; diff --git a/src/store/configureStore.dev.js b/src/store/configureStore.dev.js new file mode 100644 index 0000000..3803efa --- /dev/null +++ b/src/store/configureStore.dev.js @@ -0,0 +1,25 @@ +//This file merely configures the store for hot reloading. +//This boilerplate file is likely to be the same for each project that uses Redux. +//With Redux, the actual stores are in /reducers. + +import { createStore } from 'redux'; +import rootReducer from '../reducers'; + +export default function configureStore(initialState) { + let store; + if (window.devToolsExtension) { //Enable Redux devtools if the extension is installed in developer's browser + store = window.devToolsExtension()(createStore)(rootReducer, initialState); + } else { + store = createStore(rootReducer, initialState); + } + + if (module.hot) { + // Enable Webpack hot module replacement for reducers + module.hot.accept('../reducers', () => { + const nextReducer = require('../reducers'); + store.replaceReducer(nextReducer); + }); + } + + return store; +} diff --git a/src/store/configureStore.js b/src/store/configureStore.js new file mode 100644 index 0000000..a4c9e7a --- /dev/null +++ b/src/store/configureStore.js @@ -0,0 +1,5 @@ +if (process.env.NODE_ENV === 'production') { + module.exports = require('./configureStore.prod') +} else { + module.exports = require('./configureStore.dev') +} diff --git a/src/store/configureStore.prod.js b/src/store/configureStore.prod.js new file mode 100644 index 0000000..2872f85 --- /dev/null +++ b/src/store/configureStore.prod.js @@ -0,0 +1,6 @@ +import { createStore } from 'redux'; +import rootReducer from '../reducers'; + +export default function configureStore(initialState) { + return createStore(rootReducer, initialState); +} diff --git a/src/styles/styles.scss b/src/styles/styles.scss new file mode 100644 index 0000000..16389fe --- /dev/null +++ b/src/styles/styles.scss @@ -0,0 +1,31 @@ +/* Variables */ +$vin-blue: #5bb7db; +$vin-green: #60b044; +$vin-red: #ff0000; + +/* Styles */ +body { + font: 14px 'Helvetica Neue', Helvetica, Arial, sans-serif; + line-height: 1.4em; + color: #4d4d4d; + min-width: 230px; + max-width: 550px; + margin: 0 auto; + -webkit-font-smoothing: antialiased; + -moz-font-smoothing: antialiased; + font-smoothing: antialiased; + font-weight: 300; +} + +td { + padding: 12px; +} + +h2 { + color: $vin-blue; +} + +.savings { color: $vin-green; } +.loss { color: $vin-red; } +input.small { width: 46px; } +td.fuel-savings-label { width: 175px; } diff --git a/tools/build.js b/tools/build.js new file mode 100644 index 0000000..a7876f7 --- /dev/null +++ b/tools/build.js @@ -0,0 +1,45 @@ +// More info on Webpack's Node API here: https://webpack.github.io/docs/node.js-api.html +// Allowing console calls below since this is a build file. +/*eslint-disable no-console */ +import webpack from 'webpack'; +import webpackConfigBuilder from '../webpack.config'; +import colors from 'colors'; +import { argv as args } from 'yargs'; + +process.env.NODE_ENV = 'production'; // this assures React is built in prod mode and that the Babel dev config doesn't apply. + +const webpackConfig = webpackConfigBuilder(process.env.NODE_ENV); + +webpack(webpackConfig).run((err, stats) => { + const inSilentMode = args.s; // set to true when -s is passed on the command + + if (!inSilentMode) { + console.log('Generating minified bundle for production use via Webpack...'.bold.blue); + } + + if (err) { // so a fatal error occurred. Stop here. + console.log(err.bold.red); + + return 1; + } + + const jsonStats = stats.toJson(); + + if (jsonStats.hasErrors) { + return jsonStats.errors.map(error => console.log(error.red)); + } + + if (jsonStats.hasWarnings && !inSilentMode) { + console.log('Webpack generated the following warnings: '.bold.yellow); + jsonStats.warnings.map(warning => console.log(warning.yellow)); + } + + if (!inSilentMode) { + console.log(`Webpack stats: ${stats}`); + } + + // if we got this far, the build succeeded. + console.log('Your app has been compiled in production mode and written to /dist. It\'s ready to roll!'.green.bold); + + return 0; +}); diff --git a/tools/buildHtml.js b/tools/buildHtml.js new file mode 100644 index 0000000..8745e6b --- /dev/null +++ b/tools/buildHtml.js @@ -0,0 +1,45 @@ +// This script copies src/index.html into /dist/index.html +// and adds TrackJS error tracking code for use in production +// when useTrackJs is set to true below and a trackJsToken is provided. +// This is a good example of using Node and cheerio to do a simple file transformation. +// In this case, the transformation is useful since we only want to track errors in the built production code. + +// Allowing console calls below since this is a build file. +/*eslint-disable no-console */ + +import fs from 'fs'; +import colors from 'colors'; +import cheerio from 'cheerio'; + +const useTrackJs = true; // If you choose not to use TrackJS, just set this to false and the build warning will go away. +const trackJsToken = ''; // If you choose to use TrackJS, insert your unique token here. To get a token, go to https://trackjs.com + +fs.readFile('src/index.html', 'utf8', (err, markup) => { + if (err) { + return console.log(err); + } + + const $ = cheerio.load(markup); + + // since a separate spreadsheet is only utilized for the production build, need to dynamically add this here. + $('head').prepend(''); + + if (useTrackJs) { + if (trackJsToken) { + const trackJsCode = ``; + + $('head').prepend(trackJsCode); // add TrackJS tracking code to the top of + } else { + console.log('To track JavaScript errors, sign up for a free trial at TrackJS.com and enter your token in /tools/build.html on line 10.'.yellow); + } + } + + fs.writeFile('dist/index.html', $.html(), 'utf8', function (err) { + if (err) { + return console.log(err); + } + }); + + console.log('index.html written to /dist'.green); +}); + diff --git a/tools/distServer.js b/tools/distServer.js new file mode 100644 index 0000000..f5c485b --- /dev/null +++ b/tools/distServer.js @@ -0,0 +1,19 @@ +// This file configures a web server for testing the production build +// on your local machine. + +import browserSync from 'browser-sync'; + +// Run Browsersync +browserSync({ + port: 3000, + ui: { + port: 3001 + }, + server: { + baseDir: 'dist' + }, + + files: [ + 'src/*.html' + ] +}); diff --git a/tools/srcServer.js b/tools/srcServer.js new file mode 100644 index 0000000..7115714 --- /dev/null +++ b/tools/srcServer.js @@ -0,0 +1,44 @@ +// This file configures the development web server +// which supports hot reloading and synchronized testing. + +// Require Browsersync along with webpack and middleware for it +import browserSync from 'browser-sync'; +import webpack from 'webpack'; +import webpackDevMiddleware from 'webpack-dev-middleware'; +import webpackHotMiddleware from 'webpack-hot-middleware'; +import webpackConfigBuilder from '../webpack.config'; + +const webpackConfig = webpackConfigBuilder('development'); +const bundler = webpack(webpackConfig); + +// Run Browsersync and use middleware for Hot Module Replacement +browserSync({ + server: { + baseDir: 'src', + + middleware: [ + webpackDevMiddleware(bundler, { + // Dev middleware can't access config, so we provide publicPath + publicPath: webpackConfig.output.publicPath, + + // pretty colored output + stats: { colors: true }, + + // Set to false to display a list of each file that is being bundled. + noInfo: true + + // for other settings see + // http://webpack.github.io/docs/webpack-dev-middleware.html + }), + + // bundler should be the same as above + webpackHotMiddleware(bundler) + ] + }, + + // no need to watch '*.js' here, webpack will take care of it for us, + // including full page reloads if HMR won't work + files: [ + 'src/*.html' + ] +}); diff --git a/webpack.config.js b/webpack.config.js new file mode 100644 index 0000000..18d8853 --- /dev/null +++ b/webpack.config.js @@ -0,0 +1,83 @@ +// For info about this file refer to webpack and webpack-hot-middleware documentation +// Rather than having hard coded webpack.config.js for each environment, this +// file generates a webpack config for the environment passed to the getConfig method. +import webpack from 'webpack'; +import path from 'path'; +import ExtractTextPlugin from 'extract-text-webpack-plugin'; + +const developmentEnvironment = 'development' ; +const productionEnvironment = 'production'; +const testEnvironment = 'test'; + +const getPlugins = function (env) { + const GLOBALS = { + 'process.env.NODE_ENV': JSON.stringify(env), + __DEV__: env === developmentEnvironment + }; + + const plugins = [ + new webpack.optimize.OccurenceOrderPlugin(), + new webpack.DefinePlugin(GLOBALS) //Tells React to build in prod mode. https://facebook.github.io/react/downloads.html + ]; + + switch (env) { + case productionEnvironment: + plugins.push(new ExtractTextPlugin('styles.css')); + plugins.push(new webpack.optimize.DedupePlugin()); + plugins.push(new webpack.optimize.UglifyJsPlugin()); + break; + + case developmentEnvironment: + plugins.push(new webpack.HotModuleReplacementPlugin()); + plugins.push(new webpack.NoErrorsPlugin()); + break; + } + + return plugins; +}; + +const getEntry = function (env) { + const entry = []; + + if (env === developmentEnvironment ) { // only want hot reloading when in dev. + entry.push('webpack-hot-middleware/client'); + } + + entry.push('./src/index'); + + return entry; +}; + +const getLoaders = function (env) { + const loaders = [{ test: /\.js$/, include: path.join(__dirname, 'src'), loaders: ['babel', 'eslint'] }]; + + if (env === productionEnvironment ) { + // generate separate physical stylesheet for production build using ExtractTextPlugin. This provides separate caching and avoids a flash of unstyled content on load. + loaders.push({test: /(\.css|\.scss)$/, loader: ExtractTextPlugin.extract("css?sourceMap!sass?sourceMap")}); + } else { + loaders.push({test: /(\.css|\.scss)$/, loaders: ['style', 'css?sourceMap', 'sass?sourceMap']}); + } + + return loaders; +}; + +function getConfig(env) { + return { + debug: true, + devtool: env === productionEnvironment ? 'source-map' : 'cheap-module-eval-source-map', // more info:https://webpack.github.io/docs/build-performance.html#sourcemaps and https://webpack.github.io/docs/configuration.html#devtool + noInfo: true, // set to false to see a list of every file being bundled. + entry: getEntry(env), + target: env === testEnvironment ? 'node' : 'web', // necessary per https://webpack.github.io/docs/testing.html#compile-and-test + output: { + path: __dirname + '/dist', // Note: Physical files are only output by the production build task `npm run build`. + publicPath: '', + filename: 'bundle.js' + }, + plugins: getPlugins(env), + module: { + loaders: getLoaders(env) + } + }; +} + +export default getConfig;