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
+
+
+
+
+
+
+
\ 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