diff --git a/lab-nathan/.babelrc b/lab-nathan/.babelrc new file mode 100644 index 0000000..cf6ae40 --- /dev/null +++ b/lab-nathan/.babelrc @@ -0,0 +1,4 @@ +{ + "presets": ["es2015", "react"], + "plugins": ["transform-object-rest-spread"] +} \ No newline at end of file diff --git a/lab-nathan/.dev.env b/lab-nathan/.dev.env new file mode 100644 index 0000000..dd660b8 --- /dev/null +++ b/lab-nathan/.dev.env @@ -0,0 +1 @@ +NODE_ENV='dev' \ No newline at end of file diff --git a/lab-nathan/.eslintrc.json b/lab-nathan/.eslintrc.json new file mode 100644 index 0000000..467fe81 --- /dev/null +++ b/lab-nathan/.eslintrc.json @@ -0,0 +1,10 @@ +{ + "extends": ["eslint:recommended", "plugin:react/recommended", "plugin:jest/recommended"], + "parser": "babel-eslint", + "env": { + "browser": true, + "node": true, + "jest": true + }, + "plugins": ["jest"] +} \ No newline at end of file diff --git a/lab-nathan/.gitignore b/lab-nathan/.gitignore new file mode 100644 index 0000000..893f389 --- /dev/null +++ b/lab-nathan/.gitignore @@ -0,0 +1,4 @@ +node_modules +build +.vscode +coverage \ No newline at end of file diff --git a/lab-nathan/package.json b/lab-nathan/package.json new file mode 100644 index 0000000..a6831d4 --- /dev/null +++ b/lab-nathan/package.json @@ -0,0 +1,58 @@ +{ + "name": "lab-nathan", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "build": "webpack", + "watch": "webpack-dev-server --inline --hot", + "test": "jest --coverage", + "test-watch": "jest --watchAll" + }, + "jest": { + "globals": { + "__DEBUG__": false, + "process.env": { + "NODE_ENV": "testing" + } + } + }, + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": { + "babel-core": "^6.26.0", + "babel-loader": "^7.1.2", + "babel-plugin-transform-object-rest-spread": "^6.26.0", + "babel-preset-es2015": "^6.24.1", + "babel-preset-react": "^6.24.1", + "clean-webpack-plugin": "^0.1.16", + "css-loader": "^0.28.5", + "dotenv": "^4.0.0", + "extract-text-webpack-plugin": "^3.0.0", + "file-loader": "^0.11.2", + "html-webpack-plugin": "^2.30.1", + "jest": "^20.0.4", + "node-sass": "^4.5.3", + "prop-types": "^15.5.10", + "react": "^15.6.1", + "react-dom": "^15.6.1", + "react-redux": "^5.0.6", + "react-router-dom": "^4.2.2", + "react-test-renderer": "^15.6.1", + "redux": "^3.7.2", + "sass-loader": "^6.0.6", + "superagent": "^3.6.0", + "uglifyjs-webpack-plugin": "^0.4.6", + "url-loader": "^0.5.9", + "uuid": "^3.1.0", + "webpack": "^3.5.5", + "webpack-dev-server": "^2.7.1" + }, + "devDependencies": { + "babel-eslint": "^7.2.3", + "eslint": "^4.5.0", + "eslint-plugin-jest": "^20.0.3", + "eslint-plugin-react": "^7.3.0" + } +} diff --git a/lab-nathan/src/__test__/category-actions.test.js b/lab-nathan/src/__test__/category-actions.test.js new file mode 100644 index 0000000..45d5518 --- /dev/null +++ b/lab-nathan/src/__test__/category-actions.test.js @@ -0,0 +1,29 @@ +import { categoryCreate, categoryUpdate, categoryDelete } from '../actions/category-actions.js'; + +describe('Category Actions', () => { + test('categoryCreate returns a CATEGORY_CREATE action', () => { + let action = categoryCreate({ name: 'test title' }); + expect(action.type).toEqual('CATEGORY_CREATE'); + expect(action.payload.id).toBeTruthy(); + expect(action.payload.timestamp).toBeTruthy(); + expect(action.payload.name).toBe('test title'); + }); + + test('categoryDelete returns a CATEGORY_DELETE action', () => { + let category = { id: '01234', timestamp: new Date(), title: 'test title' }; + let action = categoryDelete(category); + expect(action).toEqual({ + type: 'CATEGORY_DELETE', + payload: category + }); + }); + + test('categoryUpdate returns a CATEGORY_UPDATE action', () => { + let category = { id: '01234', timestamp: new Date(), title: 'test title' }; + let action = categoryUpdate(category); + expect(action).toEqual({ + type: 'CATEGORY_UPDATE', + payload: category + }); + }); +}); \ No newline at end of file diff --git a/lab-nathan/src/__test__/category-reducer.test.js b/lab-nathan/src/__test__/category-reducer.test.js new file mode 100644 index 0000000..2fc0b74 --- /dev/null +++ b/lab-nathan/src/__test__/category-reducer.test.js @@ -0,0 +1,71 @@ +import categoryReducer from '../reducers/category-reducer.js'; + +describe('Category Reducer', () => { + test('initialState should be an empty array', () => { + let result = categoryReducer(undefined, { type: null }); + expect(result).toEqual([]); + }); + + test('if no action type is presented, the state should be returned', () => { + let state = [ + { id: 'someid', title: 'some title', budget: 234, timestamp: Date.now() }, + { id: 'anotherid', title: 'another title', budget: 234, timestamp: Date.now() } + ]; + let result = categoryReducer(state, { type: null }); + expect(result).toEqual(state); + }); + + test('CATEGORY_CREATE should append a category to the categories array', () => { + let action = { + type: 'CATEGORY_CREATE', + payload: { name: 'sample payload', id: "1", budget: 30, timestamp: Date.now() } + }; + + let result = categoryReducer([], action); + expect(result.length).toBe(1); + expect(result[0]).toBe(action.payload); + }); + + test('CATEGORY_UPDATE should update a category', () => { + let createAction = { + type: 'CATEGORY_CREATE', + payload: { name: 'sample payload', id: "1", budget: 30, timestamp: Date.now() } + }; + + let createResult = categoryReducer([], createAction); + + let createAction2 = { + type: 'CATEGORY_CREATE', + payload: { name: 'sample payload 2', id: "2", budget: 30, timestamp: Date.now() } + }; + + let createResult2 = categoryReducer(createResult, createAction2); + + let updateAction = { + type: 'CATEGORY_UPDATE', + payload: { name: 'updated payload', id: "1", budget: 30, timestamp: Date.now() } + }; + + let updateResult = categoryReducer(createResult2, updateAction); + + expect(updateResult[0]).toBe(updateAction.payload); + }); + + test('CATEGORY_DELETE should delete a category', () => { + let createAction = { + type: 'CATEGORY_CREATE', + payload: { name: 'sample payload', id: "1", budget: 30, timestamp: Date.now() } + }; + + let createResult = categoryReducer([], createAction); + + let deleteAction = { + type: 'CATEGORY_DELETE', + payload: { name: 'sample payload', id: "1", budget: 234, timestamp: Date.now() } + }; + + let deleteResult = categoryReducer(createResult, deleteAction); + + expect(deleteResult.length).toBe(0); + }); +}); \ No newline at end of file diff --git a/lab-nathan/src/__test__/expense-actions.test.js b/lab-nathan/src/__test__/expense-actions.test.js new file mode 100644 index 0000000..44c4550 --- /dev/null +++ b/lab-nathan/src/__test__/expense-actions.test.js @@ -0,0 +1,30 @@ +import { expenseCreate, expenseUpdate, expenseDelete } from '../actions/expense-actions.js'; + +describe('Expense Actions', () => { + test('expenseCreate returns a EXPENSE_CREATE action', () => { + let {payload, type} = expenseCreate({ name: 'test name', budget: 1342 }); + expect(type).toEqual('EXPENSE_CREATE'); + expect(payload.id).toBeTruthy(); + expect(payload.timestamp).toBeTruthy(); + expect(payload.name).toBe('test name'); + expect(payload.budget).toBe(1342); + }); + + test('expenseDelete returns a EXPENSE_DELETE action', () => { + let expense = { id: '01234', timestamp: new Date(), name: 'test name', budget: 1 }; + let action = expenseDelete(expense); + expect(action).toEqual({ + type: 'EXPENSE_DELETE', + payload: expense + }); + }); + + test('expenseUpdate returns a EXPENSE_UPDATE action', () => { + let expense = { id: '01234', timestamp: new Date(), name: 'test name', budget: 1 }; + let action = expenseUpdate(expense); + expect(action).toEqual({ + type: 'EXPENSE_UPDATE', + payload: expense + }); + }); +}); \ No newline at end of file diff --git a/lab-nathan/src/__test__/expense-reducer.test.js b/lab-nathan/src/__test__/expense-reducer.test.js new file mode 100644 index 0000000..6ba5f98 --- /dev/null +++ b/lab-nathan/src/__test__/expense-reducer.test.js @@ -0,0 +1,72 @@ +import expenseReducer from '../reducers/expense-reducer.js'; + +describe('Expense Reducer', () => { + test('initialState should be an empty object', () => { + let result = expenseReducer(undefined, { type: null }); + expect(result).toEqual({}); + }); + + test('if no action type is presented, the state should be returned', () => { + let state = { + 0: [{ id: 'someid', title: 'some title', price: 2, categoryId: "2" }], + 1: [{ id: 'anotherid', title: 'another title', price: 2, categoryId: "2" }] + }; + let result = expenseReducer(state, { type: null }); + expect(result).toEqual(state); + }); + + test('CATEGORY_CREATE should create an empty array at the supplied category id', () => { + let action = { + type: 'CATEGORY_CREATE', + payload: { name: 'sample payload', id: "1", budget: 30, timestamp: Date.now() } + }; + + let result = expenseReducer({}, action); + expect(result[1]).toEqual([]); + }); + + test('CATEGORY_DELETE should delete the array with the supplied category id', () => { + let action = { + type: 'CATEGORY_DELETE', + payload: { name: 'sample payload', id: "1", budget: 30, timestamp: Date.now() } + }; + + let result = expenseReducer({ 1: [ { id: 'someid', categoryId: '1', title: 'another title', price: 3 } ] }, action); + expect(result).toEqual({}); + }); + + test('EXPENSE_CREATE should append a expense to the categories array', () => { + let action = { + type: 'EXPENSE_CREATE', + payload: { id: 'someid', categoryId: '1', title: 'another title', price: 34, timestamp: Date.now() } + }; + + let result = expenseReducer({ 1: [] }, action); + expect(result[1].length).toBe(1); + expect(result[1][0]).toBe(action.payload); + }); + + test('EXPENSE_UPDATE should update a expense', () => { + let action = { + type: 'EXPENSE_UPDATE', + payload: { id: 'someid', categoryId: '1', title: 'updated title', price: 342, timestamp: Date.now() } + }; + + let result = expenseReducer({ + 1: [ { id: 'someid', categoryId: '1', title: 'another title', price: 342, timestamp: Date.now() }, { id: 'someid2', categoryId: '1', title: 'another title2', price: 342, timestamp: Date.now() } ], + 2: [ { id: 'someid', categoryId: '2', title: 'another title', price: 342, timestamp: Date.now() }, { id: 'someid2', categoryId: '2', title: 'another title2', price: 342, timestamp: Date.now() } ] + }, action); + + expect(result[1][0]).toBe(action.payload); + }); + + test('EXPENSE_DELETE should delete a expense', () => { + let action = { + type: 'EXPENSE_DELETE', + payload: { id: 'someid', categoryId: '1', title: 'updated title', price: 342, timestamp: Date.now() } + }; + + let result = expenseReducer({ 1: [ { id: 'someid', categoryId: '1', title: 'another title', price: 342, timestamp: Date.now() } ] }, action); + expect(result[1].length).toBe(0); + }); +}); \ No newline at end of file diff --git a/lab-nathan/src/actions/category-actions.js b/lab-nathan/src/actions/category-actions.js new file mode 100644 index 0000000..ec0aff0 --- /dev/null +++ b/lab-nathan/src/actions/category-actions.js @@ -0,0 +1,20 @@ +import uuid from 'uuid/v1'; + +export const categoryCreate = (category) => { + category.id = uuid(); + category.timestamp = new Date(); + return { + type: 'CATEGORY_CREATE', + payload: category + } +}; + +export const categoryUpdate = (category) => ({ + type: 'CATEGORY_UPDATE', + payload: category +}); + +export const categoryDelete = (category) => ({ + type: 'CATEGORY_DELETE', + payload: category +}); diff --git a/lab-nathan/src/actions/expense-actions.js b/lab-nathan/src/actions/expense-actions.js new file mode 100644 index 0000000..6582567 --- /dev/null +++ b/lab-nathan/src/actions/expense-actions.js @@ -0,0 +1,20 @@ +import uuid from 'uuid/v1'; + +export const expenseCreate = (expense) => { + expense.id = uuid(); + expense.timestamp = new Date(); + return { + type: 'EXPENSE_CREATE', + payload: expense + } +}; + +export const expenseUpdate = (expense) => ({ + type: 'EXPENSE_UPDATE', + payload: expense +}); + +export const expenseDelete = (expense) => ({ + type: 'EXPENSE_DELETE', + payload: expense +}); diff --git a/lab-nathan/src/components/app.js b/lab-nathan/src/components/app.js new file mode 100644 index 0000000..c3ee0ac --- /dev/null +++ b/lab-nathan/src/components/app.js @@ -0,0 +1,33 @@ +import React from 'react'; +import { BrowserRouter, Route } from 'react-router-dom'; +import { createStore, applyMiddleware } from 'redux'; +import { Provider } from 'react-redux'; +import reduxReporter from '../lib/redux-reporter.js'; +import reducers from '../reducers/reducers.js'; +import Dashboard from './dashboard.js'; + +const store = createStore(reducers, applyMiddleware(reduxReporter)); + +class App extends React.Component { + componentDidMount() { + store.subscribe(() => { + console.log('__STATE__', store.getState()); + }); + + store.dispatch({ type: null }); + } + + render() { + return ( + +
+ + + +
+
+ ); + } +} + +export default App; \ No newline at end of file diff --git a/lab-nathan/src/components/category-form.js b/lab-nathan/src/components/category-form.js new file mode 100644 index 0000000..3ff9010 --- /dev/null +++ b/lab-nathan/src/components/category-form.js @@ -0,0 +1,67 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +class CategoryForm extends React.Component { + constructor(props) { + super(props); + + this.state = { + name: props.category ? props.category.name : '', + budget: props.category ? props.category.budget : 0, + dragging: false + }; + + this.handleNameChange = this.handleNameChange.bind(this); + this.handleBudgetChange = this.handleBudgetChange.bind(this); + this.handleSubmit = this.handleSubmit.bind(this); + } + + handleNameChange(e) { + this.setState({ name: e.target.value }); + } + + handleBudgetChange(e) { + this.setState({ budget: Number(e.target.value) }); + } + + handleSubmit(e) { + e.preventDefault(); + + let category = Object.assign({}, this.state); + + if (this.props.category) { + category.id = this.props.category.id; + category.timestamp = this.props.category.timestamp; + } + + e.target.reset(); + + this.props.onComplete(category); + } + + render() { + return ( +
+ + + +
+ ); + } +} + +CategoryForm.propTypes = { + buttonText: PropTypes.string, + onComplete: PropTypes.func, + category: PropTypes.object +}; + +export default CategoryForm; \ No newline at end of file diff --git a/lab-nathan/src/components/category-item.js b/lab-nathan/src/components/category-item.js new file mode 100644 index 0000000..611fa3a --- /dev/null +++ b/lab-nathan/src/components/category-item.js @@ -0,0 +1,96 @@ +import './category-item.scss'; +import React from 'react'; +import PropTypes from 'prop-types'; +import CategoryForm from './category-form.js'; +import ExpenseForm from './expense-form.js'; +import ExpenseItem from './expense-item.js'; +import ItemHeader from './item-header.js'; +import { connect } from 'react-redux'; + +import { + expenseCreate, +} from '../actions/expense-actions.js'; + +import { + categoryUpdate, + categoryDelete +} from '../actions/category-actions.js'; + +class CategoryItem extends React.Component { + constructor(props) { + super(props); + + this.state = { + showUpdate: false + } + } + + render() { + let {category, expenses} = this.props; + let categoryExpenses = expenses[category.id]; + let amountSpent = 0; + + for (let expense of categoryExpenses) { + amountSpent += expense.price; + } + + let overBudget = amountSpent > category.budget; + return ( +
+ this.props.categoryDelete(category)} itemUpdate={() => this.setState({ showUpdate: !this.state.showUpdate })}> + {category.name} + ${category.budget - amountSpent} + + + {this.state.showUpdate + ? + { + this.setState({ showUpdate: false }); + return this.props.categoryUpdate(category); + }} /> + : + null + } + + + + {this.props.expenses[this.props.category.id].map((expense) => + + )} +
+ ); + } +} + +CategoryItem.propTypes = { + category: PropTypes.object, + categoryUpdate: PropTypes.func, + categoryDelete: PropTypes.func, + expenseCreate: PropTypes.func, + expenses: PropTypes.object +}; + +const mapStateToProps = (state) => ({ + expenses: state.expenses, + categories: state.categories +}); + +const mapDispatchToProps = (dispatch) => { + return { + categoryUpdate: (category) => dispatch(categoryUpdate(category)), + categoryDelete: (category) => dispatch(categoryDelete(category)), + expenseCreate: (category) => dispatch(expenseCreate(category)) + } +} + +export default connect(mapStateToProps, mapDispatchToProps)(CategoryItem); + diff --git a/lab-nathan/src/components/category-item.scss b/lab-nathan/src/components/category-item.scss new file mode 100644 index 0000000..cbfef67 --- /dev/null +++ b/lab-nathan/src/components/category-item.scss @@ -0,0 +1,56 @@ +@import '../style/lib/vars'; + +.category-item { + padding: $gutter-large; + min-width: 20%; + + & .category-form { + padding-left: 0; + padding-right: 0; + background-color: transparent; + border-bottom: none; + } + + & .item-header { + display: flex; + align-items: baseline; + border-bottom: 1px dotted $black; + padding: $gutter-xsmall 0; + } +} + +.item-header { + & .item-header-left { + flex: 1; + font-size: 1.25rem; + display: flex; + align-items: baseline; + + & .category-title { + font-size: 1.75rem; + font-weight: bold; + margin-right: ($gutter-small + $gutter-xsmall) / 2; + } + } + + & button { + background: transparent; + border: none; + height: initial; + cursor: pointer; + outline: none; + padding: 0 5px; + + &:active { + color: red; + } + } +} + +.positive { + color: #0a0; +} + +.negative { + color: red; +} diff --git a/lab-nathan/src/components/dashboard.js b/lab-nathan/src/components/dashboard.js new file mode 100644 index 0000000..36e9cbc --- /dev/null +++ b/lab-nathan/src/components/dashboard.js @@ -0,0 +1,44 @@ +import './dashboard.scss'; +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import CategoryItem from './category-item.js'; +import CategoryForm from './category-form.js'; +import { categoryCreate, categoryMove } from '../actions/category-actions.js'; + +class DashboardContainer extends React.Component { + render() { + return ( +
+ +
+ {this.props.categories.map((item, index) => + + )} +
+
+ ); + } +} + +DashboardContainer.propTypes = { + categories: PropTypes.array, + categoryCreate: PropTypes.func, +}; + +const mapStateToProps = (state) => ({ + categories: state.categories +}); + +const mapDispatchToProps = (dispatch) => { + return { + categoryCreate: (category) => dispatch(categoryCreate(category)), + } +} + +export default connect(mapStateToProps, mapDispatchToProps)(DashboardContainer); \ No newline at end of file diff --git a/lab-nathan/src/components/dashboard.scss b/lab-nathan/src/components/dashboard.scss new file mode 100644 index 0000000..fffbe49 --- /dev/null +++ b/lab-nathan/src/components/dashboard.scss @@ -0,0 +1,14 @@ +@import '../style/lib/vars'; + +.category-form { + background-color: #eee; + border-bottom: 1px solid #ccc; + padding: $gutter-small $gutter-large; +} + +.category-container { + display: flex; + flex-flow: row; + flex: 1; + overflow-x: auto; +} \ No newline at end of file diff --git a/lab-nathan/src/components/expense-form.js b/lab-nathan/src/components/expense-form.js new file mode 100644 index 0000000..a4297aa --- /dev/null +++ b/lab-nathan/src/components/expense-form.js @@ -0,0 +1,72 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +class ExpenseForm extends React.Component { + constructor(props) { + super(props); + + this.state = { + name: props.expense ? props.expense.name : '', + price: props.expense ? props.expense.price : 0, + categoryId: props.expense ? props.expense.categoryId : '', + }; + + this.handleNameChange = this.handleNameChange.bind(this); + this.handlePriceChange = this.handlePriceChange.bind(this); + this.handleSubmit = this.handleSubmit.bind(this); + } + + handleNameChange(e) { + this.setState({ name: e.target.value }); + } + + handlePriceChange(e) { + this.setState({ price: Number(e.target.value) }); + } + + handleSubmit(e) { + e.preventDefault(); + + let expense = Object.assign({}, this.state); + + if (this.props.expense) { + expense.id = this.props.expense.id; + expense.timestamp = this.props.expense.timestamp; + expense.categoryId = this.props.expense.categoryId; + } + else { + expense.categoryId = this.props.categoryId; + } + + e.target.reset(); + + this.props.onComplete(expense); + } + + render() { + return ( +
+ + + +
+ ); + } +} + +ExpenseForm.propTypes = { + buttonText: PropTypes.string, + onComplete: PropTypes.func, + expense: PropTypes.object, + categoryId: PropTypes.string +}; + +export default ExpenseForm; \ No newline at end of file diff --git a/lab-nathan/src/components/expense-item.js b/lab-nathan/src/components/expense-item.js new file mode 100644 index 0000000..5baa226 --- /dev/null +++ b/lab-nathan/src/components/expense-item.js @@ -0,0 +1,65 @@ +import './expense-item.scss'; +import React from 'react'; +import PropTypes from 'prop-types'; +import ExpenseForm from './expense-form.js'; +import ItemHeader from './item-header.js'; +import { connect } from 'react-redux'; + +import { + expenseUpdate, + expenseDelete +} from '../actions/expense-actions.js'; + +class ExpenseItem extends React.Component { + constructor(props) { + super(props); + + this.state = { + showUpdate: false + } + } + + render() { + return ( +
+ this.props.expenseDelete(this.props.expense)} itemUpdate={() => this.setState({ showUpdate: !this.state.showUpdate })}> + {this.props.expense.name} + ${this.props.expense.price} + + + {this.state.showUpdate + ? + { + this.setState({ showUpdate: false }); + return this.props.expenseUpdate(expense); + }} /> + : + null + } +
+ ); + } +} + +ExpenseItem.propTypes = { + expense: PropTypes.object, + expenseUpdate: PropTypes.func, + expenseDelete: PropTypes.func +}; + +const mapStateToProps = (state) => ({ + expenses: state.expenses +}); + +const mapDispatchToProps = (dispatch) => { + return { + expenseUpdate: (expense) => dispatch(expenseUpdate(expense)), + expenseDelete: (expense) => dispatch(expenseDelete(expense)) + } +} + +export default connect(mapStateToProps, mapDispatchToProps)(ExpenseItem); + diff --git a/lab-nathan/src/components/expense-item.scss b/lab-nathan/src/components/expense-item.scss new file mode 100644 index 0000000..d63a018 --- /dev/null +++ b/lab-nathan/src/components/expense-item.scss @@ -0,0 +1,22 @@ +@import '../style/lib/vars'; + +.expense-item { + background-color: white; + + & .item-header { + border-bottom: none; + } +} + +.item-header { + & .item-header-left { + flex: 1; + font-size: 1.25rem; + display: flex; + align-items: baseline; + + & .expense-title { + margin-right: ($gutter-small + $gutter-xsmall) / 2; + } + } +} \ No newline at end of file diff --git a/lab-nathan/src/components/item-header.js b/lab-nathan/src/components/item-header.js new file mode 100644 index 0000000..3aa2bfc --- /dev/null +++ b/lab-nathan/src/components/item-header.js @@ -0,0 +1,42 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +class ItemHeader extends React.Component { + constructor(props) { + super(props); + + this.handleUpdate = this.handleUpdate.bind(this); + this.handleDelete = this.handleDelete.bind(this); + } + + handleDelete(e) { + e.preventDefault(); + this.props.itemDelete(); + } + + handleUpdate(e) { + e.preventDefault(); + this.props.itemUpdate(); + } + + render() { + return ( +
+ + {this.props.children} + + + +
+ ); + } +} + +ItemHeader.propTypes = { + children: PropTypes.array, + itemUpdate: PropTypes.func, + itemDelete: PropTypes.func, +}; + +export default ItemHeader; + diff --git a/lab-nathan/src/index.html b/lab-nathan/src/index.html new file mode 100644 index 0000000..45623fe --- /dev/null +++ b/lab-nathan/src/index.html @@ -0,0 +1,14 @@ + + + + Expenses + + +
+

Expense Tracker

+
+
+ + + \ No newline at end of file diff --git a/lab-nathan/src/lib/redux-reporter.js b/lab-nathan/src/lib/redux-reporter.js new file mode 100644 index 0000000..ed8b647 --- /dev/null +++ b/lab-nathan/src/lib/redux-reporter.js @@ -0,0 +1,16 @@ +let reporter = store => next => action => { + console.log('__ACTION__', action); + + try { + let result = next(action); + console.log('__STATE__', store.getState()); + return result; + } + catch (error) { + error.action = action; + console.error('__ERROR__', error); + return error; + } +} + +export default reporter; \ No newline at end of file diff --git a/lab-nathan/src/lib/validators.js b/lab-nathan/src/lib/validators.js new file mode 100644 index 0000000..8f86349 --- /dev/null +++ b/lab-nathan/src/lib/validators.js @@ -0,0 +1,11 @@ +export const validateCategory = (category) => { + if (!category.id || !category.name || !category.budget || !category.timestamp) { + throw new Error('VALIDATION ERROR: category must include id, name, budget, and timestamp.'); + } +} + +export const validateExpense = (expense) => { + if (!expense.id || !expense.timestamp || !expense.price || !expense.categoryId) { + throw new Error('VALIDATION ERROR: expense must include id, timestamp, price, and category id'); + } +} \ No newline at end of file diff --git a/lab-nathan/src/main.js b/lab-nathan/src/main.js new file mode 100644 index 0000000..29a5a14 --- /dev/null +++ b/lab-nathan/src/main.js @@ -0,0 +1,6 @@ +import './style/main.scss'; +import React from 'react'; +import ReactDom from 'react-dom'; +import App from './components/app.js'; + +ReactDom.render(, document.getElementById('root')); \ No newline at end of file diff --git a/lab-nathan/src/reducers/category-reducer.js b/lab-nathan/src/reducers/category-reducer.js new file mode 100644 index 0000000..65aa541 --- /dev/null +++ b/lab-nathan/src/reducers/category-reducer.js @@ -0,0 +1,24 @@ +import { validateCategory } from '../lib/validators.js'; + +const categoryReducer = function(categories = [], action) { + let { type, payload } = action; + + switch (type) { + case 'CATEGORY_CREATE': { + validateCategory(payload); + return [...categories, payload]; + } + case 'CATEGORY_UPDATE': { + validateCategory(payload); + return categories.map(category => category.id === payload.id ? payload : category); + } + case 'CATEGORY_DELETE': { + validateCategory(payload); + return categories.filter(category => category.id !== payload.id); + } + default: + return categories; + } +} + +export default categoryReducer; \ No newline at end of file diff --git a/lab-nathan/src/reducers/expense-reducer.js b/lab-nathan/src/reducers/expense-reducer.js new file mode 100644 index 0000000..55bfffb --- /dev/null +++ b/lab-nathan/src/reducers/expense-reducer.js @@ -0,0 +1,40 @@ +import { validateCategory, validateExpense } from '../lib/validators.js'; + +const expenseReducer = function(expenses = {}, action) { + let { type, payload } = action; + + switch (type) { + case 'CATEGORY_CREATE': { + validateCategory(action.payload); + return {...expenses, [payload.id]: []}; + } + case 'CATEGORY_DELETE': { + validateCategory(action.payload); + let newExpenses = { ...expenses }; + delete newExpenses[payload.id]; + return newExpenses; + } + case 'EXPENSE_CREATE': { + validateExpense(action.payload); + let { categoryId } = payload; + let categoryExpenses = expenses[categoryId]; + return { ...expenses, [categoryId]: [...categoryExpenses, payload] }; + } + case 'EXPENSE_UPDATE': { + validateExpense(action.payload); + let { categoryId } = payload; + let categoryExpenses = expenses[categoryId]; + return {...expenses, [categoryId]: categoryExpenses.map(expense => expense.id === payload.id ? payload : expense) }; + } + case 'EXPENSE_DELETE': { + validateExpense(action.payload); + let { categoryId } = payload; + let categoryExpenses = expenses[categoryId]; + return {...expenses, [categoryId]: categoryExpenses.filter(expense => expense.id !== payload.id) }; + } + default: + return expenses; + } +} + +export default expenseReducer; \ No newline at end of file diff --git a/lab-nathan/src/reducers/reducers.js b/lab-nathan/src/reducers/reducers.js new file mode 100644 index 0000000..2307a40 --- /dev/null +++ b/lab-nathan/src/reducers/reducers.js @@ -0,0 +1,8 @@ +import { combineReducers } from 'redux'; +import categoryReducer from './category-reducer.js'; +import expenseReducer from './expense-reducer.js'; + +export default combineReducers({ + categories: categoryReducer, + expenses: expenseReducer +}); \ No newline at end of file diff --git a/lab-nathan/src/style/base/_base.scss b/lab-nathan/src/style/base/_base.scss new file mode 100644 index 0000000..2bffb30 --- /dev/null +++ b/lab-nathan/src/style/base/_base.scss @@ -0,0 +1,41 @@ +html, body, section, main { + height: 100%; +} + +body { + background: $white; + font-family: $header-font-family; + color: $black; + display: flex; + flex-flow: column; +} + +h1 { + font-size: $extra-large-font; + font-weight: bold; +} + +button { + background: linear-gradient(to bottom, #fff, #ddd); + border-radius: $border-radius; + border: $input-border; + box-sizing: border-box; + padding: 0 $gutter-small + $gutter-xsmall; + height: 30px; +} + +form { + padding: $gutter-xsmall 0; +} + +input { + margin-right: $gutter-small; + padding: $gutter-xsmall; + border: $input-border; +} + +main { + display: flex; + flex-direction: column; +} + \ No newline at end of file diff --git a/lab-nathan/src/style/base/_reset.scss b/lab-nathan/src/style/base/_reset.scss new file mode 100644 index 0000000..d31a672 --- /dev/null +++ b/lab-nathan/src/style/base/_reset.scss @@ -0,0 +1,48 @@ +/* http://meyerweb.com/eric/tools/css/reset/ + v2.0 | 20110126 + License: none (public domain) +*/ + +html, body, div, span, applet, object, iframe, +h1, h2, h3, h4, h5, h6, p, blockquote, pre, +a, abbr, acronym, address, big, cite, code, +del, dfn, em, img, ins, kbd, q, s, samp, +small, strike, strong, sub, sup, tt, var, +b, u, i, center, +dl, dt, dd, ol, ul, li, +fieldset, form, label, legend, +table, caption, tbody, tfoot, thead, tr, th, td, +article, aside, canvas, details, embed, +figure, figcaption, footer, header, hgroup, +menu, nav, output, ruby, section, summary, +time, mark, audio, video { + margin: 0; + padding: 0; + border: 0; + font-size: 100%; + font: inherit; + vertical-align: baseline; +} +/* HTML5 display-role reset for older browsers */ +article, aside, details, figcaption, figure, +footer, header, hgroup, menu, nav, section { + display: block; +} +body { + line-height: 1; +} +ol, ul { + list-style: none; +} +blockquote, q { + quotes: none; +} +blockquote:before, blockquote:after, +q:before, q:after { + content: ''; + content: none; +} +table { + border-collapse: collapse; + border-spacing: 0; +} \ No newline at end of file diff --git a/lab-nathan/src/style/layout/content.scss b/lab-nathan/src/style/layout/content.scss new file mode 100644 index 0000000..06d6b63 --- /dev/null +++ b/lab-nathan/src/style/layout/content.scss @@ -0,0 +1,3 @@ +#root { + flex: 1; +} \ No newline at end of file diff --git a/lab-nathan/src/style/layout/footer.scss b/lab-nathan/src/style/layout/footer.scss new file mode 100644 index 0000000..e83abb2 --- /dev/null +++ b/lab-nathan/src/style/layout/footer.scss @@ -0,0 +1,9 @@ +footer { + background: $bookend-background; + min-height: $bookend-height / 2; + display: flex; + align-items: center; + padding: 0 $gutter-large; + color: white; + font-size: 0.8rem; +} \ No newline at end of file diff --git a/lab-nathan/src/style/layout/header.scss b/lab-nathan/src/style/layout/header.scss new file mode 100644 index 0000000..fe730b0 --- /dev/null +++ b/lab-nathan/src/style/layout/header.scss @@ -0,0 +1,8 @@ +header { + background: $bookend-background; + min-height: $bookend-height; + display: flex; + align-items: center; + padding: 0 $gutter-large; + color: $white; +} \ No newline at end of file diff --git a/lab-nathan/src/style/lib/_vars.scss b/lab-nathan/src/style/lib/_vars.scss new file mode 100644 index 0000000..5e95cdb --- /dev/null +++ b/lab-nathan/src/style/lib/_vars.scss @@ -0,0 +1,21 @@ +$black: #000; +$white: #fff; + +$primary: #E94F37; +$secondary: hsl(218, 98%, 95%); +$tertiary: #4387fd; + +$extra-large-font: 2rem; +$large-font: 1.25rem; + +$header-font-family: 'Helvetica Neue', sans-serif; +$font-primary: $black; +$font-secondary: $white; +$bookend-height: 75px; +$bookend-background: linear-gradient(0deg, #c00, $primary); +$gutter-large: 26px; +$gutter-small: $gutter-large / 2; +$gutter-xsmall: $gutter-small / 2; +$gutter-xxsmall: $gutter-xsmall / 2; +$border-radius: 0; +$input-border: 1px solid #ccc; \ No newline at end of file diff --git a/lab-nathan/src/style/main.scss b/lab-nathan/src/style/main.scss new file mode 100644 index 0000000..1ed5f1e --- /dev/null +++ b/lab-nathan/src/style/main.scss @@ -0,0 +1,6 @@ +@import './lib/vars'; +@import './base/reset'; +@import './base/base'; +@import './layout/header'; +@import './layout/content'; +@import './layout/footer'; \ No newline at end of file diff --git a/lab-nathan/webpack.config.js b/lab-nathan/webpack.config.js new file mode 100644 index 0000000..25929e9 --- /dev/null +++ b/lab-nathan/webpack.config.js @@ -0,0 +1,86 @@ +'use strict'; + +const dotenv = require('dotenv'); +dotenv.config({ path: `${__dirname}/.dev.env` }); + +const production = process.env.NODE_ENV === 'production'; +const { DefinePlugin, EnvironmentPlugin } = require('webpack'); +const HtmlPlugin = require('html-webpack-plugin'); +const ExtractPlugin = require('extract-text-webpack-plugin'); +const CleanPlugin = require('clean-webpack-plugin'); +const UglifyPlugin = require('uglifyjs-webpack-plugin'); + +let plugins = [ + new EnvironmentPlugin(['NODE_ENV']), + new ExtractPlugin('bundle-[hash].css'), + new HtmlPlugin({ template: `${__dirname}/src/index.html` }), + new DefinePlugin({ __DEBUG__: JSON.stringify(!production) }) +] + +if (production) { + plugins = plugins.concate([ new CleanPlugin(), new UglifyPlugin() ]); +} + +module.exports = { + plugins, + entry: `${__dirname}/src/main.js`, + devServer: { + historyApiFallback: true + }, + devtool: production ? undefined : 'eval', + output: { + path: `${__dirname}/build`, + filename: 'bundle-[hash].js', + publicPath: process.env.CDN_URL + }, + module: { + rules: [ + { + test: /\.js$/, + exclude: /node_modules/, + loader: 'babel-loader' + }, + { + test: /\.scss$/, + loader: ExtractPlugin.extract(['css-loader','sass-loader']) + }, + { + test: /\.(woff|woff2|ttf|eot|glyph|\.svg)$/, + use: [ + { + loader: 'url-loader', + options: { + limit: 10000, + name: 'font/[name].[ext]' + } + } + ] + }, + { + test: /\.(jpg|jpeg|gif|png|tiff|svg)$/, + exclude: /\.glyph.svg/, + use: [ + { + loader: 'url-loader', + options: { + limit: 6000, + name: 'image/[name].[ext]' + }, + } + ] + }, + { + test: /\.(mp3|aac|aiff|wav|flac|m4a|mp4|ogg|ape)$/, + exclude: /\.glyph.svg/, + use: [ + { + loader: 'file-loader', + options: { + name: 'audio/[name].[ext]' + } + } + ] + } + ] + } +}; \ No newline at end of file