| @ -1,9 +0,0 @@ | |||||
| 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 }; | |||||
| } | |||||
| @ -0,0 +1,38 @@ | |||||
| import { | |||||
| SOCKET_SET_OPENING, SOCKET_SET_CLOSING, SOCKET_SEND_MESSAGE | |||||
| } from "../constants/ActionTypes"; | |||||
| export default (socketClient) => ({ | |||||
| open: url => { | |||||
| const action = { type: SOCKET_SET_OPENING }; | |||||
| try { | |||||
| socketClient.open(url); | |||||
| } catch (err) { | |||||
| action.error = true; | |||||
| action.payload = err; | |||||
| } | |||||
| return action; | |||||
| }, | |||||
| close: () => { | |||||
| const action = { type: SOCKET_SET_CLOSING }; | |||||
| try { | |||||
| socketClient.close(); | |||||
| } catch (err) { | |||||
| action.error = true; | |||||
| action.payload = err; | |||||
| } | |||||
| return action; | |||||
| }, | |||||
| send: message => { | |||||
| const action = { type: SOCKET_SEND_MESSAGE }; | |||||
| try { | |||||
| socketClient.send(JSON.stringify(message)); | |||||
| } catch (err) { | |||||
| action.error = true; | |||||
| action.payload = err; | |||||
| } | |||||
| return action; | |||||
| } | |||||
| }); | |||||
| @ -0,0 +1,34 @@ | |||||
| import { | |||||
| SOCKET_SET_CLOSED, | |||||
| SOCKET_SET_ERROR, | |||||
| SOCKET_SET_OPEN, | |||||
| SOCKET_RECEIVE_MESSAGE | |||||
| } from "../constants/ActionTypes"; | |||||
| export default { | |||||
| onclose: event => ({ | |||||
| type: SOCKET_SET_CLOSED, | |||||
| payload: { | |||||
| code: event.code | |||||
| } | |||||
| }), | |||||
| onerror: event => ({ | |||||
| type: SOCKET_SET_ERROR | |||||
| }), | |||||
| onopen: event => ({ | |||||
| type: SOCKET_SET_OPEN | |||||
| }), | |||||
| onmessage: event => { | |||||
| const action = { type: SOCKET_RECEIVE_MESSAGE }; | |||||
| try { | |||||
| action.payload = JSON.parse(event.data); | |||||
| } catch (err) { | |||||
| action.error = true; | |||||
| action.payload = err; | |||||
| } | |||||
| return action; | |||||
| } | |||||
| }; | |||||
| @ -1,10 +0,0 @@ | |||||
| 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; | |||||
| } | |||||
| } | |||||
| @ -1,26 +0,0 @@ | |||||
| 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'); | |||||
| }); | |||||
| }); | |||||
| }); | |||||
| @ -1,67 +0,0 @@ | |||||
| 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; | |||||
| @ -1,123 +0,0 @@ | |||||
| 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); | |||||
| }); | |||||
| }); | |||||
| }); | |||||
| @ -1,40 +0,0 @@ | |||||
| 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; | |||||
| @ -1,60 +0,0 @@ | |||||
| 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); | |||||
| }); | |||||
| }); | |||||
| }); | |||||
| @ -1,58 +0,0 @@ | |||||
| 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; | |||||
| @ -1,38 +0,0 @@ | |||||
| 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); | |||||
| }); | |||||
| }); | |||||
| }); | |||||
| @ -1,72 +0,0 @@ | |||||
| 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 ( | |||||
| <div> | |||||
| <h2>Fuel Savings Analysis</h2> | |||||
| <table> | |||||
| <tbody> | |||||
| <tr> | |||||
| <td><label htmlFor="newMpg">New Vehicle MPG</label></td> | |||||
| <td><FuelSavingsTextInput onChange={fuelSavingsKeypress} name="newMpg" value={settings.newMpg} /></td> | |||||
| </tr> | |||||
| <tr> | |||||
| <td><label htmlFor="tradeMpg">Trade-in MPG</label></td> | |||||
| <td><FuelSavingsTextInput onChange={fuelSavingsKeypress} name="tradeMpg" value={settings.tradeMpg} /></td> | |||||
| </tr> | |||||
| <tr> | |||||
| <td><label htmlFor="newPpg">New Vehicle price per gallon</label></td> | |||||
| <td><FuelSavingsTextInput onChange={fuelSavingsKeypress} name="newPpg" value={settings.newPpg} /></td> | |||||
| </tr> | |||||
| <tr> | |||||
| <td><label htmlFor="tradePpg">Trade-in price per gallon</label></td> | |||||
| <td><FuelSavingsTextInput onChange={fuelSavingsKeypress} name="tradePpg" value={settings.tradePpg} /></td> | |||||
| </tr> | |||||
| <tr> | |||||
| <td><label htmlFor="milesDriven">Miles Driven</label></td> | |||||
| <td> | |||||
| <FuelSavingsTextInput onChange={fuelSavingsKeypress} name="milesDriven" value={settings.milesDriven} /> miles per | |||||
| <select name="milesDrivenTimeframe" onChange={onTimeframeChange} value={settings.milesDrivenTimeframe}> | |||||
| <option value="week">Week</option> | |||||
| <option value="month">Month</option> | |||||
| <option value="year">Year</option> | |||||
| </select> | |||||
| </td> | |||||
| </tr> | |||||
| <tr> | |||||
| <td><label>Date Modified</label></td> | |||||
| <td>{settings.dateModified}</td> | |||||
| </tr> | |||||
| </tbody> | |||||
| </table> | |||||
| <hr/> | |||||
| {settings.necessaryDataIsProvidedToCalculateSavings ? <FuelSavingsResults savings={settings.savings} /> : null} | |||||
| <input type="submit" value="Save" onClick={save} /> | |||||
| </div> | |||||
| ); | |||||
| }; | |||||
| FuelSavingsApp.propTypes = { | |||||
| actions: PropTypes.object.isRequired, | |||||
| appState: PropTypes.object.isRequired | |||||
| }; | |||||
| export default FuelSavingsApp; | |||||
| @ -1,8 +0,0 @@ | |||||
| import chai from 'chai'; | |||||
| import FuelSavingsCalculatorForm from './FuelSavingsApp'; | |||||
| chai.should(); | |||||
| describe('Fuel Savings Calculator Component', () => { | |||||
| }); | |||||
| @ -1,49 +0,0 @@ | |||||
| 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 ( | |||||
| <table> | |||||
| <tbody> | |||||
| <tr> | |||||
| <td className="fuel-savings-label">{resultLabel}</td> | |||||
| <td> | |||||
| <table> | |||||
| <tbody> | |||||
| <tr> | |||||
| <td>Monthly</td> | |||||
| <td>1 Year</td> | |||||
| <td>3 Year</td> | |||||
| </tr> | |||||
| <tr> | |||||
| <td className={savingsClass}>{props.savings.monthly}</td> | |||||
| <td className={savingsClass}>{props.savings.annual}</td> | |||||
| <td className={savingsClass}>{props.savings.threeYear}</td> | |||||
| </tr> | |||||
| </tbody> | |||||
| </table> | |||||
| </td> | |||||
| </tr> | |||||
| </tbody> | |||||
| </table> | |||||
| ); | |||||
| }; | |||||
| //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; | |||||
| @ -1,63 +0,0 @@ | |||||
| 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'); | |||||
| }); | |||||
| }); | |||||
| }); | |||||
| @ -1,31 +0,0 @@ | |||||
| 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 ( | |||||
| <input className="small" | |||||
| type="text" | |||||
| placeholder={props.placeholder} | |||||
| value={props.value} | |||||
| onChange={handleChange} /> | |||||
| ); | |||||
| }; | |||||
| FuelSavingsTextInput.propTypes = { | |||||
| name: PropTypes.string.isRequired, | |||||
| onChange: PropTypes.func.isRequired, | |||||
| placeholder: PropTypes.string, | |||||
| value: PropTypes.oneOfType([ | |||||
| PropTypes.string, | |||||
| PropTypes.number | |||||
| ]) | |||||
| }; | |||||
| export default FuelSavingsTextInput; | |||||
| @ -0,0 +1,21 @@ | |||||
| import React, {PropTypes} from 'react'; | |||||
| const App = (props) => { | |||||
| const { onClick, socket } = props; | |||||
| return ( | |||||
| <div> | |||||
| <h1>Solstice web UI</h1> | |||||
| <div>Socket state: {socket.state}</div> | |||||
| <button onClick={onClick}> | |||||
| Connect | |||||
| </button> | |||||
| </div> | |||||
| ); | |||||
| }; | |||||
| App.propTypes = { | |||||
| onClick: PropTypes.func.isRequired, | |||||
| socket: PropTypes.object.isRequired | |||||
| }; | |||||
| export default App; | |||||
| @ -1,2 +1,7 @@ | |||||
| export const SAVE_FUEL_SAVINGS = 'SAVE_FUEL_SAVINGS'; | |||||
| export const CALCULATE_FUEL_SAVINGS = 'CALCULATE_FUEL_SAVINGS'; | |||||
| export const SOCKET_SET_OPEN = Symbol("SOCKET_SET_OPEN"); | |||||
| export const SOCKET_SET_OPENING = Symbol("SOCKET_SET_OPENING"); | |||||
| export const SOCKET_SET_CLOSED = Symbol("SOCKET_SET_CLOSED"); | |||||
| export const SOCKET_SET_CLOSING = Symbol("SOCKET_SET_CLOSING"); | |||||
| export const SOCKET_SET_ERROR = Symbol("SOCKET_SET_ERROR"); | |||||
| export const SOCKET_RECEIVE_MESSAGE = Symbol("SOCKET_RECEIVE_MESSAGE"); | |||||
| export const SOCKET_SEND_MESSAGE = Symbol("SOCKET_SEND_MESSAGE"); | |||||
| @ -1,37 +1,47 @@ | |||||
| // This file bootstraps the app with the boilerplate necessary | // This file bootstraps the app with the boilerplate necessary | ||||
| // to support hot reloading in Redux | // 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'; | |||||
| import React, {PropTypes} from "react"; | |||||
| import { bindActionCreators } from "redux"; | |||||
| import { connect } from "react-redux"; | |||||
| class App extends React.Component { | |||||
| render() { | |||||
| import SolsticeApp from "../components/SolsticeApp"; | |||||
| import socketActionsFactory from "../actions/socketActionsFactory"; | |||||
| import socketHandlerActions from "../actions/socketHandlerActions"; | |||||
| import SocketClient from "../utils/SocketClient"; | |||||
| const App = (props) => { | |||||
| const onClick = (event) => { | |||||
| const url = "ws://localhost:2244"; | |||||
| props.actions.socketActions.open(url); | |||||
| }; | |||||
| return ( | return ( | ||||
| <FuelSavingsApp appState={this.props.appState} actions={this.props.actions}/> | |||||
| <SolsticeApp socket={props.socket} onClick={onClick} /> | |||||
| ); | ); | ||||
| } | |||||
| } | |||||
| }; | |||||
| App.propTypes = { | App.propTypes = { | ||||
| actions: PropTypes.object.isRequired, | |||||
| appState: PropTypes.object.isRequired | |||||
| actions: PropTypes.object.isRequired, | |||||
| socket: PropTypes.object.isRequired | |||||
| }; | }; | ||||
| function mapStateToProps(state) { | function mapStateToProps(state) { | ||||
| return { | |||||
| appState: state.fuelSavingsAppState | |||||
| }; | |||||
| return { | |||||
| socket: state.socket | |||||
| }; | |||||
| } | } | ||||
| function mapDispatchToProps(dispatch) { | function mapDispatchToProps(dispatch) { | ||||
| return { | |||||
| actions: bindActionCreators(actions, dispatch) | |||||
| }; | |||||
| const callbacks = bindActionCreators(socketHandlerActions, dispatch); | |||||
| const socketClient = new SocketClient(callbacks); | |||||
| const socketActions = socketActionsFactory(socketClient); | |||||
| return { | |||||
| actions: { | |||||
| socketActions: bindActionCreators(socketActions, dispatch) | |||||
| } | |||||
| }; | |||||
| } | } | ||||
| export default connect( | export default connect( | ||||
| mapStateToProps, | |||||
| mapDispatchToProps | |||||
| mapStateToProps, | |||||
| mapDispatchToProps | |||||
| )(App); | )(App); | ||||
| @ -1,53 +0,0 @@ | |||||
| 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; | |||||
| } | |||||
| } | |||||
| @ -1,8 +1,8 @@ | |||||
| import { combineReducers } from 'redux'; | import { combineReducers } from 'redux'; | ||||
| import fuelSavingsAppState from './fuelSavings'; | |||||
| import socket from "./socket"; | |||||
| const rootReducer = combineReducers({ | const rootReducer = combineReducers({ | ||||
| fuelSavingsAppState | |||||
| socket | |||||
| }); | }); | ||||
| export default rootReducer; | export default rootReducer; | ||||
| @ -0,0 +1,53 @@ | |||||
| import objectAssign from "object-assign"; | |||||
| import * as types from "../constants/ActionTypes"; | |||||
| const STATE_OPENING = 0; | |||||
| const STATE_OPEN = 1; | |||||
| const STATE_CLOSING = 2; | |||||
| const STATE_CLOSED = 3; | |||||
| const initialState = { | |||||
| state: STATE_CLOSED, | |||||
| url: null | |||||
| }; | |||||
| export default function socket(state = initialState, action) { | |||||
| switch (action.type) { | |||||
| case types.SOCKET_SET_OPENING: | |||||
| if (action.error) { | |||||
| return state; | |||||
| } | |||||
| return objectAssign({}, state, { state: STATE_OPENING }); | |||||
| case types.SOCKET_SET_OPEN: | |||||
| return objectAssign({}, state, { state: STATE_OPEN }); | |||||
| case types.SOCKET_SET_CLOSING: | |||||
| if (action.error) { | |||||
| return state; | |||||
| } | |||||
| return objectAssign({}, state, { state: STATE_CLOSING }); | |||||
| case types.SOCKET_SET_CLOSED: | |||||
| return objectAssign({}, state, { state: STATE_CLOSED }); | |||||
| case types.SOCKET_SET_ERROR: | |||||
| if (state.state === STATE_OPENING) { | |||||
| return objectAssign({}, state, { state: STATE_CLOSED }); | |||||
| } | |||||
| return state; | |||||
| case types.SOCKET_RECEIVE_MESSAGE: | |||||
| console.log(`Socket received message: ${action.payload}`); | |||||
| return state; | |||||
| case types.SOCKET_SEND_MESSAGE: | |||||
| console.log("Sending message"); | |||||
| return state; | |||||
| default: | |||||
| return state; | |||||
| } | |||||
| } | |||||
| @ -0,0 +1,11 @@ | |||||
| import objectAssign from 'object-assign'; | |||||
| const initialState = { | |||||
| socketConnected: false, | |||||
| clientConnected: false, | |||||
| loggedIn: false | |||||
| }; | |||||
| export default function solsticeAppState(state = initialState, action) { | |||||
| return state; | |||||
| } | |||||
| @ -1,5 +1,5 @@ | |||||
| if (process.env.NODE_ENV === 'production') { | if (process.env.NODE_ENV === 'production') { | ||||
| module.exports = require('./configureStore.prod') | |||||
| module.exports = require('./configureStore.prod'); | |||||
| } else { | } else { | ||||
| module.exports = require('./configureStore.dev') | |||||
| module.exports = require('./configureStore.dev'); | |||||
| } | } | ||||
| @ -1,31 +1,9 @@ | |||||
| /* Variables */ | |||||
| $vin-blue: #5bb7db; | |||||
| $vin-green: #60b044; | |||||
| $vin-red: #ff0000; | |||||
| /* Styles */ | /* Styles */ | ||||
| body { | 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; | |||||
| font: 14px 'Helvetica Neue', Helvetica, Arial, sans-serif; | |||||
| line-height: 1.4em; | |||||
| -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; } | |||||
| @ -0,0 +1,31 @@ | |||||
| import { bindActionCreators } from "redux"; | |||||
| import objectAssign from "object-assign"; | |||||
| const STATE_CONNECTING = 0; | |||||
| const STATE_OPEN = 1; | |||||
| const STATE_CLOSING = 2; | |||||
| const STATE_CLOSED = 3; | |||||
| class SocketClient { | |||||
| constructor(callbacks) { | |||||
| this.callbacks = callbacks; | |||||
| } | |||||
| open(url) { | |||||
| if (this.socket && this.socket.readyState !== STATE_CLOSED) { | |||||
| throw new Error("SocketClient: socket already open"); | |||||
| } | |||||
| this.socket = new WebSocket(url); | |||||
| objectAssign(this.socket, this.callbacks); | |||||
| } | |||||
| close() { | |||||
| this.socket.close(); | |||||
| } | |||||
| send(message) { | |||||
| this.socket.send(message); | |||||
| } | |||||
| } | |||||
| export default SocketClient; | |||||