| @ -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 | |||
| // 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 ( | |||
| <FuelSavingsApp appState={this.props.appState} actions={this.props.actions}/> | |||
| <SolsticeApp socket={props.socket} onClick={onClick} /> | |||
| ); | |||
| } | |||
| } | |||
| }; | |||
| App.propTypes = { | |||
| actions: PropTypes.object.isRequired, | |||
| appState: PropTypes.object.isRequired | |||
| actions: PropTypes.object.isRequired, | |||
| socket: PropTypes.object.isRequired | |||
| }; | |||
| function mapStateToProps(state) { | |||
| return { | |||
| appState: state.fuelSavingsAppState | |||
| }; | |||
| return { | |||
| socket: state.socket | |||
| }; | |||
| } | |||
| 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( | |||
| mapStateToProps, | |||
| mapDispatchToProps | |||
| mapStateToProps, | |||
| mapDispatchToProps | |||
| )(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 fuelSavingsAppState from './fuelSavings'; | |||
| import socket from "./socket"; | |||
| const rootReducer = combineReducers({ | |||
| fuelSavingsAppState | |||
| socket | |||
| }); | |||
| 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') { | |||
| module.exports = require('./configureStore.prod') | |||
| module.exports = require('./configureStore.prod'); | |||
| } 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 */ | |||
| 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; | |||