├── .babelrc ├── .gitignore ├── jest.config.json ├── package.json ├── public ├── images │ ├── bg.jpg │ ├── favicon.png │ └── loader.gif └── index.html ├── readme.md ├── server └── server.js ├── src ├── actions │ ├── auth.js │ ├── expenses.js │ └── filters.js ├── app.js ├── components │ ├── AddExpensePage.js │ ├── EditExpensePage.js │ ├── ExpenseDashboardPage.js │ ├── ExpenseForm.js │ ├── ExpenseList.js │ ├── ExpenseListFilters.js │ ├── ExpenseListItem.js │ ├── ExpensesSummary.js │ ├── Header.js │ ├── LoadingPage.js │ ├── LoginPage.js │ └── NotFoundPage.js ├── firebase │ └── firebase.js ├── playground │ ├── destructuring.js │ ├── hoc.js │ ├── promises.js │ ├── redux-101.js │ └── redux-expensify.js ├── reducers │ ├── auth.js │ ├── expenses.js │ └── filters.js ├── routers │ ├── AppRouter.js │ ├── PrivateRoute.js │ └── PublicRoute.js ├── selectors │ ├── expenses-total.js │ └── expenses.js ├── store │ └── configureStore.js ├── styles │ ├── base │ │ ├── _base.scss │ │ └── _settings.scss │ ├── components │ │ ├── _box-layout.scss │ │ ├── _button.scss │ │ ├── _content-container.scss │ │ ├── _form.scss │ │ ├── _header.scss │ │ ├── _input-group.scss │ │ ├── _inputs.scss │ │ ├── _list.scss │ │ ├── _loader.scss │ │ ├── _page-header.scss │ │ └── _visibility.scss │ └── styles.scss └── tests │ ├── __mocks__ │ └── moment.js │ ├── actions │ ├── auth.test.js │ ├── expenses.test.js │ └── filters.test.js │ ├── components │ ├── AddExpensePage.test.js │ ├── EditExpensePage.test.js │ ├── ExpenseDashboardPage.test.js │ ├── ExpenseForm.test.js │ ├── ExpenseList.test.js │ ├── ExpenseListFilters.test.js │ ├── ExpenseListItem.test.js │ ├── ExpensesSummary.test.js │ ├── Header.test.js │ ├── LoadingPage.test.js │ ├── LoginPage.test.js │ ├── NotFoundPage.test.js │ └── __snapshots__ │ │ ├── AddExpensePage.test.js.snap │ │ ├── EditExpensePage.test.js.snap │ │ ├── ExpenseDashboardPage.test.js.snap │ │ ├── ExpenseForm.test.js.snap │ │ ├── ExpenseList.test.js.snap │ │ ├── ExpenseListFilters.test.js.snap │ │ ├── ExpenseListItem.test.js.snap │ │ ├── ExpensesSummary.test.js.snap │ │ ├── Header.test.js.snap │ │ ├── LoadingPage.test.js.snap │ │ ├── LoginPage.test.js.snap │ │ └── NotFoundPage.test.js.snap │ ├── fixtures │ ├── expenses.js │ └── filters.js │ ├── reducers │ ├── auth.test.js │ ├── expenses.test.js │ └── filters.test.js │ ├── selectors │ ├── expenses-total.test.js │ └── expenses.test.js │ └── setupTests.js ├── webpack.config.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "env", 4 | "react" 5 | ], 6 | "plugins": [ 7 | "transform-class-properties", 8 | "transform-object-rest-spread" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | public/dist/ 3 | .env.test 4 | .env.development 5 | -------------------------------------------------------------------------------- /jest.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "snapshotSerializers": [ 3 | "enzyme-to-json/serializer" 4 | ], 5 | "setupFiles": [ 6 | "/src/tests/setupTests.js" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "budget-app", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "author": "Andrew Mead", 6 | "license": "MIT", 7 | "scripts": { 8 | "build:dev": "webpack", 9 | "build:prod": "webpack -p --env production", 10 | "dev-server": "webpack-dev-server", 11 | "test": "cross-env NODE_ENV=test jest --config=jest.config.json", 12 | "start": "node server/server.js", 13 | "heroku-postbuild": "yarn run build:prod" 14 | }, 15 | "dependencies": { 16 | "babel-cli": "6.24.1", 17 | "babel-core": "6.25.0", 18 | "babel-loader": "7.1.1", 19 | "babel-plugin-transform-class-properties": "6.24.1", 20 | "babel-plugin-transform-object-rest-spread": "6.23.0", 21 | "babel-polyfill": "6.26.0", 22 | "babel-preset-env": "1.5.2", 23 | "babel-preset-react": "6.24.1", 24 | "css-loader": "0.28.4", 25 | "express": "^4.16.3", 26 | "extract-text-webpack-plugin": "3.0.0", 27 | "firebase": "^5.0.4", 28 | "history": "4.7.2", 29 | "moment": "^2.22.2", 30 | "node-sass": "^4.9.0", 31 | "normalize.css": "7.0.0", 32 | "numeral": "2.0.6", 33 | "react": "15.6.1", 34 | "react-addons-shallow-compare": "15.6.0", 35 | "react-dates": "12.3.0", 36 | "react-dom": "15.6.1", 37 | "react-modal": "2.2.2", 38 | "react-redux": "5.0.5", 39 | "react-router-dom": "4.1.2", 40 | "redux": "3.7.2", 41 | "redux-mock-store": "1.2.3", 42 | "redux-thunk": "2.2.0", 43 | "sass-loader": "6.0.6", 44 | "style-loader": "0.18.2", 45 | "uuid": "3.1.0", 46 | "validator": "8.0.0", 47 | "webpack": "3.1.0" 48 | }, 49 | "devDependencies": { 50 | "cross-env": "5.0.5", 51 | "dotenv": "4.0.0", 52 | "enzyme": "2.9.1", 53 | "enzyme-to-json": "1.5.1", 54 | "jest": "20.0.4", 55 | "react-test-renderer": "15.6.1", 56 | "webpack-dev-server": "2.5.1" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /public/images/bg.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andrewjmead/react-course-2-expensify-app/c4b7515b378a4d971e5e6ec8ece539e8e46c37fc/public/images/bg.jpg -------------------------------------------------------------------------------- /public/images/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andrewjmead/react-course-2-expensify-app/c4b7515b378a4d971e5e6ec8ece539e8e46c37fc/public/images/favicon.png -------------------------------------------------------------------------------- /public/images/loader.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andrewjmead/react-course-2-expensify-app/c4b7515b378a4d971e5e6ec8ece539e8e46c37fc/public/images/loader.gif -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Budget App 8 | 9 | 10 | 11 | 12 | 13 |
14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Git Commands 2 | 3 | git init - Create a new git repo 4 | git status - View the changes to your project code 5 | git add - Add files to staging area 6 | git commit - Creates a new commit with files from staging area 7 | git log - View recent commits 8 | -------------------------------------------------------------------------------- /server/server.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const express = require('express'); 3 | const app = express(); 4 | const publicPath = path.join(__dirname, '..', 'public'); 5 | const port = process.env.PORT || 3000; 6 | 7 | app.use(express.static(publicPath)); 8 | 9 | app.get('*', (req, res) => { 10 | res.sendFile(path.join(publicPath, 'index.html')); 11 | }); 12 | 13 | app.listen(port, () => { 14 | console.log('Server is up!'); 15 | }); 16 | -------------------------------------------------------------------------------- /src/actions/auth.js: -------------------------------------------------------------------------------- 1 | import { firebase, googleAuthProvider } from '../firebase/firebase'; 2 | 3 | export const login = (uid) => ({ 4 | type: 'LOGIN', 5 | uid 6 | }); 7 | 8 | export const startLogin = () => { 9 | return () => { 10 | return firebase.auth().signInWithPopup(googleAuthProvider); 11 | }; 12 | }; 13 | 14 | export const logout = () => ({ 15 | type: 'LOGOUT' 16 | }); 17 | 18 | export const startLogout = () => { 19 | return () => { 20 | return firebase.auth().signOut(); 21 | }; 22 | }; 23 | -------------------------------------------------------------------------------- /src/actions/expenses.js: -------------------------------------------------------------------------------- 1 | import uuid from 'uuid'; 2 | import database from '../firebase/firebase'; 3 | 4 | // ADD_EXPENSE 5 | export const addExpense = (expense) => ({ 6 | type: 'ADD_EXPENSE', 7 | expense 8 | }); 9 | 10 | export const startAddExpense = (expenseData = {}) => { 11 | return (dispatch, getState) => { 12 | const uid = getState().auth.uid; 13 | const { 14 | description = '', 15 | note = '', 16 | amount = 0, 17 | createdAt = 0 18 | } = expenseData; 19 | const expense = { description, note, amount, createdAt }; 20 | 21 | return database.ref(`users/${uid}/expenses`).push(expense).then((ref) => { 22 | dispatch(addExpense({ 23 | id: ref.key, 24 | ...expense 25 | })); 26 | }); 27 | }; 28 | }; 29 | 30 | // REMOVE_EXPENSE 31 | export const removeExpense = ({ id } = {}) => ({ 32 | type: 'REMOVE_EXPENSE', 33 | id 34 | }); 35 | 36 | export const startRemoveExpense = ({ id } = {}) => { 37 | return (dispatch, getState) => { 38 | const uid = getState().auth.uid; 39 | return database.ref(`users/${uid}/expenses/${id}`).remove().then(() => { 40 | dispatch(removeExpense({ id })); 41 | }); 42 | }; 43 | }; 44 | 45 | // EDIT_EXPENSE 46 | export const editExpense = (id, updates) => ({ 47 | type: 'EDIT_EXPENSE', 48 | id, 49 | updates 50 | }); 51 | 52 | export const startEditExpense = (id, updates) => { 53 | return (dispatch, getState) => { 54 | const uid = getState().auth.uid; 55 | return database.ref(`users/${uid}/expenses/${id}`).update(updates).then(() => { 56 | dispatch(editExpense(id, updates)); 57 | }); 58 | }; 59 | }; 60 | 61 | // SET_EXPENSES 62 | export const setExpenses = (expenses) => ({ 63 | type: 'SET_EXPENSES', 64 | expenses 65 | }); 66 | 67 | export const startSetExpenses = () => { 68 | return (dispatch, getState) => { 69 | const uid = getState().auth.uid; 70 | return database.ref(`users/${uid}/expenses`).once('value').then((snapshot) => { 71 | const expenses = []; 72 | 73 | snapshot.forEach((childSnapshot) => { 74 | expenses.push({ 75 | id: childSnapshot.key, 76 | ...childSnapshot.val() 77 | }); 78 | }); 79 | 80 | dispatch(setExpenses(expenses)); 81 | }); 82 | }; 83 | }; 84 | -------------------------------------------------------------------------------- /src/actions/filters.js: -------------------------------------------------------------------------------- 1 | // SET_TEXT_FILTER 2 | export const setTextFilter = (text = '') => ({ 3 | type: 'SET_TEXT_FILTER', 4 | text 5 | }); 6 | 7 | // SORT_BY_DATE 8 | export const sortByDate = () => ({ 9 | type: 'SORT_BY_DATE' 10 | }); 11 | 12 | // SORT_BY_AMOUNT 13 | export const sortByAmount = () => ({ 14 | type: 'SORT_BY_AMOUNT' 15 | }); 16 | 17 | // SET_START_DATE 18 | export const setStartDate = (startDate) => ({ 19 | type: 'SET_START_DATE', 20 | startDate 21 | }); 22 | 23 | // SET_END_DATE 24 | export const setEndDate = (endDate) => ({ 25 | type: 'SET_END_DATE', 26 | endDate 27 | }); 28 | -------------------------------------------------------------------------------- /src/app.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { Provider } from 'react-redux'; 4 | import AppRouter, { history } from './routers/AppRouter'; 5 | import configureStore from './store/configureStore'; 6 | import { startSetExpenses } from './actions/expenses'; 7 | import { login, logout } from './actions/auth'; 8 | import getVisibleExpenses from './selectors/expenses'; 9 | import 'normalize.css/normalize.css'; 10 | import './styles/styles.scss'; 11 | import 'react-dates/lib/css/_datepicker.css'; 12 | import { firebase } from './firebase/firebase'; 13 | import LoadingPage from './components/LoadingPage'; 14 | 15 | const store = configureStore(); 16 | const jsx = ( 17 | 18 | 19 | 20 | ); 21 | let hasRendered = false; 22 | const renderApp = () => { 23 | if (!hasRendered) { 24 | ReactDOM.render(jsx, document.getElementById('app')); 25 | hasRendered = true; 26 | } 27 | }; 28 | 29 | ReactDOM.render(, document.getElementById('app')); 30 | 31 | firebase.auth().onAuthStateChanged((user) => { 32 | if (user) { 33 | store.dispatch(login(user.uid)); 34 | store.dispatch(startSetExpenses()).then(() => { 35 | renderApp(); 36 | if (history.location.pathname === '/') { 37 | history.push('/dashboard'); 38 | } 39 | }); 40 | } else { 41 | store.dispatch(logout()); 42 | renderApp(); 43 | history.push('/'); 44 | } 45 | }); 46 | -------------------------------------------------------------------------------- /src/components/AddExpensePage.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { connect } from 'react-redux'; 3 | import ExpenseForm from './ExpenseForm'; 4 | import { startAddExpense } from '../actions/expenses'; 5 | 6 | export class AddExpensePage extends React.Component { 7 | onSubmit = (expense) => { 8 | this.props.startAddExpense(expense); 9 | this.props.history.push('/'); 10 | }; 11 | render() { 12 | return ( 13 |
14 |
15 |
16 |

Add Expense

17 |
18 |
19 |
20 | 23 |
24 |
25 | ); 26 | } 27 | } 28 | 29 | const mapDispatchToProps = (dispatch) => ({ 30 | startAddExpense: (expense) => dispatch(startAddExpense(expense)) 31 | }); 32 | 33 | export default connect(undefined, mapDispatchToProps)(AddExpensePage); 34 | -------------------------------------------------------------------------------- /src/components/EditExpensePage.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { connect } from 'react-redux'; 3 | import ExpenseForm from './ExpenseForm'; 4 | import { startEditExpense, startRemoveExpense } from '../actions/expenses'; 5 | 6 | export class EditExpensePage extends React.Component { 7 | onSubmit = (expense) => { 8 | this.props.startEditExpense(this.props.expense.id, expense); 9 | this.props.history.push('/'); 10 | }; 11 | onRemove = () => { 12 | this.props.startRemoveExpense({ id: this.props.expense.id }); 13 | this.props.history.push('/'); 14 | }; 15 | render() { 16 | return ( 17 |
18 |
19 |
20 |

Edit Expense

21 |
22 |
23 |
24 | 28 | 29 |
30 |
31 | ); 32 | } 33 | }; 34 | 35 | const mapStateToProps = (state, props) => ({ 36 | expense: state.expenses.find((expense) => expense.id === props.match.params.id) 37 | }); 38 | 39 | const mapDispatchToProps = (dispatch, props) => ({ 40 | startEditExpense: (id, expense) => dispatch(startEditExpense(id, expense)), 41 | startRemoveExpense: (data) => dispatch(startRemoveExpense(data)) 42 | }); 43 | 44 | export default connect(mapStateToProps, mapDispatchToProps)(EditExpensePage); 45 | -------------------------------------------------------------------------------- /src/components/ExpenseDashboardPage.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ExpenseList from './ExpenseList'; 3 | import ExpenseListFilters from './ExpenseListFilters'; 4 | import ExpensesSummary from './ExpensesSummary'; 5 | 6 | const ExpenseDashboardPage = () => ( 7 |
8 | 9 | 10 | 11 |
12 | ); 13 | 14 | export default ExpenseDashboardPage; 15 | -------------------------------------------------------------------------------- /src/components/ExpenseForm.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import moment from 'moment'; 3 | import { SingleDatePicker } from 'react-dates'; 4 | 5 | export default class ExpenseForm extends React.Component { 6 | constructor(props) { 7 | super(props); 8 | 9 | this.state = { 10 | description: props.expense ? props.expense.description : '', 11 | note: props.expense ? props.expense.note : '', 12 | amount: props.expense ? (props.expense.amount / 100).toString() : '', 13 | createdAt: props.expense ? moment(props.expense.createdAt) : moment(), 14 | calendarFocused: false, 15 | error: '' 16 | }; 17 | } 18 | onDescriptionChange = (e) => { 19 | const description = e.target.value; 20 | this.setState(() => ({ description })); 21 | }; 22 | onNoteChange = (e) => { 23 | const note = e.target.value; 24 | this.setState(() => ({ note })); 25 | }; 26 | onAmountChange = (e) => { 27 | const amount = e.target.value; 28 | 29 | if (!amount || amount.match(/^\d{1,}(\.\d{0,2})?$/)) { 30 | this.setState(() => ({ amount })); 31 | } 32 | }; 33 | onDateChange = (createdAt) => { 34 | if (createdAt) { 35 | this.setState(() => ({ createdAt })); 36 | } 37 | }; 38 | onFocusChange = ({ focused }) => { 39 | this.setState(() => ({ calendarFocused: focused })); 40 | }; 41 | onSubmit = (e) => { 42 | e.preventDefault(); 43 | 44 | if (!this.state.description || !this.state.amount) { 45 | this.setState(() => ({ error: 'Please provide description and amount.' })); 46 | } else { 47 | this.setState(() => ({ error: '' })); 48 | this.props.onSubmit({ 49 | description: this.state.description, 50 | amount: parseFloat(this.state.amount, 10) * 100, 51 | createdAt: this.state.createdAt.valueOf(), 52 | note: this.state.note 53 | }); 54 | } 55 | }; 56 | render() { 57 | return ( 58 |
59 | {this.state.error &&

{this.state.error}

} 60 | 68 | 75 | false} 82 | /> 83 | 90 |
91 | 92 |
93 | 94 | ) 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/components/ExpenseList.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { connect } from 'react-redux'; 3 | import ExpenseListItem from './ExpenseListItem'; 4 | import selectExpenses from '../selectors/expenses'; 5 | 6 | export const ExpenseList = (props) => ( 7 |
8 |
9 |
Expenses
10 |
Expense
11 |
Amount
12 |
13 |
14 | { 15 | props.expenses.length === 0 ? ( 16 |
17 | No expenses 18 |
19 | ) : ( 20 | props.expenses.map((expense) => { 21 | return ; 22 | }) 23 | ) 24 | } 25 |
26 |
27 | ); 28 | 29 | const mapStateToProps = (state) => { 30 | return { 31 | expenses: selectExpenses(state.expenses, state.filters) 32 | }; 33 | }; 34 | 35 | export default connect(mapStateToProps)(ExpenseList); 36 | -------------------------------------------------------------------------------- /src/components/ExpenseListFilters.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { connect } from 'react-redux'; 3 | import { DateRangePicker } from 'react-dates'; 4 | import { setTextFilter, sortByDate, sortByAmount, setStartDate, setEndDate } from '../actions/filters'; 5 | 6 | export class ExpenseListFilters extends React.Component { 7 | state = { 8 | calendarFocused: null 9 | }; 10 | onDatesChange = ({ startDate, endDate }) => { 11 | this.props.setStartDate(startDate); 12 | this.props.setEndDate(endDate); 13 | }; 14 | onFocusChange = (calendarFocused) => { 15 | this.setState(() => ({ calendarFocused })); 16 | } 17 | onTextChange = (e) => { 18 | this.props.setTextFilter(e.target.value); 19 | }; 20 | onSortChange = (e) => { 21 | if (e.target.value === 'date') { 22 | this.props.sortByDate(); 23 | } else if (e.target.value === 'amount') { 24 | this.props.sortByAmount(); 25 | } 26 | }; 27 | render() { 28 | return ( 29 |
30 |
31 |
32 | 39 |
40 |
41 | 49 |
50 |
51 | false} 60 | /> 61 |
62 |
63 |
64 | ); 65 | } 66 | }; 67 | 68 | const mapStateToProps = (state) => ({ 69 | filters: state.filters 70 | }); 71 | 72 | const mapDispatchToProps = (dispatch) => ({ 73 | setTextFilter: (text) => dispatch(setTextFilter(text)), 74 | sortByDate: () => dispatch(sortByDate()), 75 | sortByAmount: () => dispatch(sortByAmount()), 76 | setStartDate: (startDate) => dispatch(setStartDate(startDate)), 77 | setEndDate: (endDate) => dispatch(setEndDate(endDate)) 78 | }); 79 | 80 | export default connect(mapStateToProps, mapDispatchToProps)(ExpenseListFilters); 81 | -------------------------------------------------------------------------------- /src/components/ExpenseListItem.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | import moment from 'moment'; 4 | import numeral from 'numeral'; 5 | 6 | const ExpenseListItem = ({ id, description, amount, createdAt }) => ( 7 | 8 |
9 |

{description}

10 | {moment(createdAt).format('MMMM Do, YYYY')} 11 |
12 |

{numeral(amount / 100).format('$0,0.00')}

13 | 14 | ); 15 | 16 | export default ExpenseListItem; 17 | -------------------------------------------------------------------------------- /src/components/ExpensesSummary.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { connect } from 'react-redux'; 3 | import { Link } from 'react-router-dom'; 4 | import numeral from 'numeral'; 5 | import selectExpenses from '../selectors/expenses'; 6 | import selectExpensesTotal from '../selectors/expenses-total'; 7 | 8 | export const ExpensesSummary = ({ expenseCount, expensesTotal }) => { 9 | const expenseWord = expenseCount === 1 ? 'expense' : 'expenses'; 10 | const formattedExpensesTotal = numeral(expensesTotal / 100).format('$0,0.00'); 11 | 12 | return ( 13 |
14 |
15 |

Viewing {expenseCount} {expenseWord} totalling {formattedExpensesTotal}

16 |
17 | Add Expense 18 |
19 |
20 |
21 | ); 22 | }; 23 | 24 | const mapStateToProps = (state) => { 25 | const visibleExpenses = selectExpenses(state.expenses, state.filters); 26 | 27 | return { 28 | expenseCount: visibleExpenses.length, 29 | expensesTotal: selectExpensesTotal(visibleExpenses) 30 | }; 31 | }; 32 | 33 | export default connect(mapStateToProps)(ExpensesSummary); 34 | -------------------------------------------------------------------------------- /src/components/Header.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | import { connect } from 'react-redux'; 4 | import { startLogout } from '../actions/auth'; 5 | 6 | export const Header = ({ startLogout }) => ( 7 |
8 |
9 |
10 | 11 |

Budget App

12 | 13 | 14 |
15 |
16 |
17 | ); 18 | 19 | const mapDispatchToProps = (dispatch) => ({ 20 | startLogout: () => dispatch(startLogout()) 21 | }); 22 | 23 | export default connect(undefined, mapDispatchToProps)(Header); 24 | -------------------------------------------------------------------------------- /src/components/LoadingPage.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const LoadingPage = () => ( 4 |
5 | 6 |
7 | ); 8 | 9 | export default LoadingPage; 10 | -------------------------------------------------------------------------------- /src/components/LoginPage.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { connect } from 'react-redux'; 3 | import { startLogin } from '../actions/auth'; 4 | 5 | export const LoginPage = ({ startLogin }) => ( 6 |
7 |
8 |

Budget App

9 |

It's time to get your expenses under control.

10 | 11 |
12 |
13 | ); 14 | 15 | const mapDispatchToProps = (dispatch) => ({ 16 | startLogin: () => dispatch(startLogin()) 17 | }); 18 | 19 | export default connect(undefined, mapDispatchToProps)(LoginPage); 20 | -------------------------------------------------------------------------------- /src/components/NotFoundPage.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | 4 | const NotFoundPage = () => ( 5 |
6 | 404 - Go home 7 |
8 | ); 9 | 10 | export default NotFoundPage; 11 | -------------------------------------------------------------------------------- /src/firebase/firebase.js: -------------------------------------------------------------------------------- 1 | import * as firebase from 'firebase'; 2 | 3 | const config = { 4 | apiKey: process.env.FIREBASE_API_KEY, 5 | authDomain: process.env.FIREBASE_AUTH_DOMAIN, 6 | databaseURL: process.env.FIREBASE_DATABASE_URL, 7 | projectId: process.env.FIREBASE_PROJECT_ID, 8 | storageBucket: process.env.FIREBASE_STORAGE_BUCKET, 9 | messagingSenderId: process.env.FIREBASE_MESSAGING_SENDER_ID 10 | }; 11 | 12 | firebase.initializeApp(config); 13 | 14 | const database = firebase.database(); 15 | const googleAuthProvider = new firebase.auth.GoogleAuthProvider(); 16 | 17 | export { firebase, googleAuthProvider, database as default }; 18 | 19 | // // child_removed 20 | // database.ref('expenses').on('child_removed', (snapshot) => { 21 | // console.log(snapshot.key, snapshot.val()); 22 | // }); 23 | 24 | // // child_changed 25 | // database.ref('expenses').on('child_changed', (snapshot) => { 26 | // console.log(snapshot.key, snapshot.val()); 27 | // }); 28 | 29 | // // child_added 30 | // database.ref('expenses').on('child_added', (snapshot) => { 31 | // console.log(snapshot.key, snapshot.val()); 32 | // }); 33 | 34 | // // database.ref('expenses') 35 | // // .once('value') 36 | // // .then((snapshot) => { 37 | // // const expenses = []; 38 | 39 | // // snapshot.forEach((childSnapshot) => { 40 | // // expenses.push({ 41 | // // id: childSnapshot.key, 42 | // // ...childSnapshot.val() 43 | // // }); 44 | // // }); 45 | 46 | // // console.log(expenses); 47 | // // }); 48 | 49 | // // database.ref('expenses').on('value', (snapshot) => { 50 | // // const expenses = []; 51 | 52 | // // snapshot.forEach((childSnapshot) => { 53 | // // expenses.push({ 54 | // // id: childSnapshot.key, 55 | // // ...childSnapshot.val() 56 | // // }); 57 | // // }); 58 | 59 | // // console.log(expenses); 60 | // // }); 61 | 62 | // database.ref('expenses').push({ 63 | // description: 'Rent', 64 | // note: '', 65 | // amount: 109500, 66 | // createdAt: 976123498763 67 | // }); 68 | 69 | 70 | 71 | 72 | 73 | 74 | // // database.ref('notes/-Krll52aVDQ3X6dOtmS7').remove(); 75 | 76 | // // database.ref('notes').push({ 77 | // // title: 'Course Topics', 78 | // // body: 'React Native, Angular, Python' 79 | // // }); 80 | 81 | // // database.ref().on('value', (snapshot) => { 82 | // // const val = snapshot.val(); 83 | // // console.log(`${val.name} is a ${val.job.title} at ${val.job.company}`); 84 | // // }) 85 | 86 | // // Setup data sub -> Andrew is a Software Developer at Amazon. 87 | 88 | // // Change the data and make sure it reprints 89 | 90 | // // database.ref('location/city') 91 | // // .once('value') 92 | // // .then((snapshot) => { 93 | // // const val = snapshot.val(); 94 | // // console.log(val); 95 | // // }) 96 | // // .catch((e) => { 97 | // // console.log('Error fetching data', e); 98 | // // }); 99 | 100 | // // database.ref().set({ 101 | // // name: 'Andrew Mead', 102 | // // age: 26, 103 | // // stressLevel: 6, 104 | // // job: { 105 | // // title: 'Software developer', 106 | // // company: 'Google' 107 | // // }, 108 | // // location: { 109 | // // city: 'Philadelphia', 110 | // // country: 'United States' 111 | // // } 112 | // // }).then(() => { 113 | // // console.log('Data is saved!'); 114 | // // }).catch((e) => { 115 | // // console.log('This failed.', e); 116 | // // }); 117 | 118 | // // database.ref().update({ 119 | // // stressLevel: 9, 120 | // // 'job/company': 'Amazon', 121 | // // 'location/city': 'Seattle' 122 | // // }); 123 | 124 | // // database.ref() 125 | // // .remove() 126 | // // .then(() => { 127 | // // console.log('Data was removed'); 128 | // // }).catch((e) => { 129 | // // console.log('Did not remove data', e); 130 | // // }); 131 | -------------------------------------------------------------------------------- /src/playground/destructuring.js: -------------------------------------------------------------------------------- 1 | // 2 | // Object destructuring 3 | // 4 | 5 | // const person = { 6 | // name: 'Andrew', 7 | // age: 27, 8 | // location: { 9 | // city: 'Philadelphia', 10 | // temp: 88 11 | // } 12 | // }; 13 | 14 | // const { name: firstName = 'Anonymous', age } = person; 15 | // console.log(`${firstName} is ${age}.`); 16 | 17 | // const { city, temp: temperature } = person.location; 18 | // if (city && temperature) { 19 | // console.log(`It's ${temperature} in ${city}.`); 20 | // } 21 | 22 | // const book = { 23 | // title: 'Ego is the Enemy', 24 | // author: 'Ryan Holiday', 25 | // publisher: { 26 | // // name: 'Penguin' 27 | // } 28 | // }; 29 | 30 | // const { name: publisherName = 'Self-Published' } = book.publisher; 31 | 32 | // console.log(publisherName); // Penguin, Self-Published 33 | 34 | // 35 | // Array destructuring 36 | // 37 | 38 | // const address = ['1299 S Juniper Street', 'Philadelphia', 'Pennsylvania', '19147']; 39 | // const [, city, state = 'New York'] = address; 40 | // console.log(`You are in ${city} ${state}.`); 41 | 42 | const item = ['Coffee (iced)', '$3.00', '$3.50', '$3.75']; 43 | const [itemName, , mediumPrice] = item; 44 | 45 | console.log(`A medium ${itemName} costs ${mediumPrice}`); 46 | -------------------------------------------------------------------------------- /src/playground/hoc.js: -------------------------------------------------------------------------------- 1 | // Higher Order Component (HOC) - A component (HOC) that renders another component 2 | // Reuse code 3 | // Render hijacking 4 | // Prop manipulation 5 | // Abstract state 6 | 7 | import React from 'react'; 8 | import ReactDOM from 'react-dom'; 9 | 10 | const Info = (props) => ( 11 |
12 |

Info

13 |

The info is: {props.info}

14 |
15 | ); 16 | 17 | const withAdminWarning = (WrappedComponent) => { 18 | return (props) => ( 19 |
20 | {props.isAdmin &&

This is private info. Please don't share!

} 21 | 22 |
23 | ); 24 | }; 25 | 26 | const requireAuthentication = (WrappedComponent) => { 27 | return (props) => ( 28 |
29 | {props.isAuthenticated ? ( 30 | 31 | ) : ( 32 |

Please login to view the info

33 | )} 34 |
35 | ); 36 | }; 37 | 38 | const AdminInfo = withAdminWarning(Info); 39 | const AuthInfo = requireAuthentication(Info); 40 | 41 | // ReactDOM.render(, document.getElementById('app')); 42 | ReactDOM.render(, document.getElementById('app')); 43 | -------------------------------------------------------------------------------- /src/playground/promises.js: -------------------------------------------------------------------------------- 1 | const promise = new Promise((resolve, reject) => { 2 | setTimeout(() => { 3 | resolve({ 4 | name: 'Andrew', 5 | age: 26 6 | }); 7 | // reject('Something went wrong!'); 8 | }, 5000); 9 | }); 10 | 11 | console.log('before'); 12 | 13 | promise.then((data) => { 14 | console.log('1', data); 15 | 16 | return new Promise((resolve, reject) => { 17 | setTimeout(() => { 18 | resolve('This is my other promise'); 19 | }, 5000); 20 | }); 21 | }).then((str) => { 22 | console.log('does this run?', str); 23 | }).catch((error) => { 24 | console.log('error: ', error); 25 | }); 26 | 27 | console.log('after'); 28 | -------------------------------------------------------------------------------- /src/playground/redux-101.js: -------------------------------------------------------------------------------- 1 | import { createStore } from 'redux'; 2 | 3 | // Action generators - functions that return action objects 4 | 5 | const incrementCount = ({ incrementBy = 1 } = {}) => ({ 6 | type: 'INCREMENT', 7 | incrementBy 8 | }); 9 | 10 | const decrementCount = ({ decrementBy = 1 } = {}) => ({ 11 | type: 'DECREMENT', 12 | decrementBy 13 | }); 14 | 15 | const setCount = ({ count }) => ({ 16 | type: 'SET', 17 | count 18 | }); 19 | 20 | const resetCount = () => ({ 21 | type: 'RESET' 22 | }); 23 | 24 | // Reducers 25 | // 1. Reducers are pure functions 26 | // 2. Never change state or actiton 27 | 28 | const countReducer = (state = { count: 0 }, action) => { 29 | switch (action.type) { 30 | case 'INCREMENT': 31 | return { 32 | count: state.count + action.incrementBy 33 | }; 34 | case 'DECREMENT': 35 | return { 36 | count: state.count - action.decrementBy 37 | }; 38 | case 'SET': 39 | return { 40 | count: action.count 41 | }; 42 | case 'RESET': 43 | return { 44 | count: 0 45 | }; 46 | default: 47 | return state; 48 | } 49 | }; 50 | 51 | const store = createStore(countReducer); 52 | 53 | const unsubscribe = store.subscribe(() => { 54 | console.log(store.getState()); 55 | }); 56 | 57 | store.dispatch(incrementCount({ incrementBy: 5 })) 58 | 59 | store.dispatch(incrementCount()); 60 | 61 | store.dispatch(resetCount()); 62 | 63 | store.dispatch(decrementCount()); 64 | 65 | store.dispatch(decrementCount({ decrementBy: 10 })); 66 | 67 | store.dispatch(setCount({ count: -100 })); 68 | -------------------------------------------------------------------------------- /src/playground/redux-expensify.js: -------------------------------------------------------------------------------- 1 | import { createStore, combineReducers } from 'redux'; 2 | import uuid from 'uuid'; 3 | 4 | // ADD_EXPENSE 5 | const addExpense = ( 6 | { 7 | description = '', 8 | note = '', 9 | amount = 0, 10 | createdAt = 0 11 | } = {} 12 | ) => ({ 13 | type: 'ADD_EXPENSE', 14 | expense: { 15 | id: uuid(), 16 | description, 17 | note, 18 | amount, 19 | createdAt 20 | } 21 | }); 22 | 23 | // REMOVE_EXPENSE 24 | const removeExpense = ({ id } = {}) => ({ 25 | type: 'REMOVE_EXPENSE', 26 | id 27 | }); 28 | 29 | // EDIT_EXPENSE 30 | const editExpense = (id, updates) => ({ 31 | type: 'EDIT_EXPENSE', 32 | id, 33 | updates 34 | }); 35 | 36 | // SET_TEXT_FILTER 37 | const setTextFilter = (text = '') => ({ 38 | type: 'SET_TEXT_FILTER', 39 | text 40 | }); 41 | 42 | // SORT_BY_DATE 43 | const sortByDate = () => ({ 44 | type: 'SORT_BY_DATE' 45 | }); 46 | 47 | // SORT_BY_AMOUNT 48 | const sortByAmount = () => ({ 49 | type: 'SORT_BY_AMOUNT' 50 | }); 51 | 52 | // SET_START_DATE 53 | const setStartDate = (startDate) => ({ 54 | type: 'SET_START_DATE', 55 | startDate 56 | }); 57 | 58 | // SET_END_DATE 59 | const setEndDate = (endDate) => ({ 60 | type: 'SET_END_DATE', 61 | endDate 62 | }); 63 | 64 | // Expenses Reducer 65 | 66 | const expensesReducerDefaultState = []; 67 | 68 | const expensesReducer = (state = expensesReducerDefaultState, action) => { 69 | switch (action.type) { 70 | case 'ADD_EXPENSE': 71 | return [ 72 | ...state, 73 | action.expense 74 | ]; 75 | case 'REMOVE_EXPENSE': 76 | return state.filter(({ id }) => id !== action.id); 77 | case 'EDIT_EXPENSE': 78 | return state.map((expense) => { 79 | if (expense.id === action.id) { 80 | return { 81 | ...expense, 82 | ...action.updates 83 | }; 84 | } else { 85 | return expense; 86 | }; 87 | }); 88 | default: 89 | return state; 90 | } 91 | }; 92 | 93 | // Filters Reducer 94 | 95 | const filtersReducerDefaultState = { 96 | text: '', 97 | sortBy: 'date', 98 | startDate: undefined, 99 | endDate: undefined 100 | }; 101 | 102 | const filtersReducer = (state = filtersReducerDefaultState, action) => { 103 | switch (action.type) { 104 | case 'SET_TEXT_FILTER': 105 | return { 106 | ...state, 107 | text: action.text 108 | }; 109 | case 'SORT_BY_AMOUNT': 110 | return { 111 | ...state, 112 | sortBy: 'amount' 113 | }; 114 | case 'SORT_BY_DATE': 115 | return { 116 | ...state, 117 | sortBy: 'date' 118 | }; 119 | case 'SET_START_DATE': 120 | return { 121 | ...state, 122 | startDate: action.startDate 123 | }; 124 | case 'SET_END_DATE': 125 | return { 126 | ...state, 127 | endDate: action.endDate 128 | }; 129 | default: 130 | return state; 131 | } 132 | }; 133 | 134 | // Get visible expenses 135 | const getVisibleExpenses = (expenses, { text, sortBy, startDate, endDate }) => { 136 | return expenses.filter((expense) => { 137 | const startDateMatch = typeof startDate !== 'number' || expense.createdAt >= startDate; 138 | const endDateMatch = typeof endDate !== 'number' || expense.createdAt <= endDate; 139 | const textMatch = expense.description.toLowerCase().includes(text.toLowerCase()); 140 | 141 | return startDateMatch && endDateMatch && textMatch; 142 | }).sort((a, b) => { 143 | if (sortBy === 'date') { 144 | return a.createdAt < b.createdAt ? 1 : -1; 145 | } else if (sortBy === 'amount') { 146 | return a.amount < b.amount ? 1 : -1; 147 | } 148 | }); 149 | }; 150 | 151 | // Store creation 152 | 153 | const store = createStore( 154 | combineReducers({ 155 | expenses: expensesReducer, 156 | filters: filtersReducer 157 | }) 158 | ); 159 | 160 | store.subscribe(() => { 161 | const state = store.getState(); 162 | const visibleExpenses = getVisibleExpenses(state.expenses, state.filters); 163 | console.log(visibleExpenses); 164 | }); 165 | 166 | const expenseOne = store.dispatch(addExpense({ description: 'Rent', amount: 100, createdAt: -21000 })); 167 | const expenseTwo = store.dispatch(addExpense({ description: 'Coffee', amount: 300, createdAt: -1000 })); 168 | 169 | // store.dispatch(removeExpense({ id: expenseOne.expense.id })); 170 | // store.dispatch(editExpense(expenseTwo.expense.id, { amount: 500 })); 171 | 172 | // store.dispatch(setTextFilter('ffe')); 173 | // store.dispatch(setTextFilter()); 174 | 175 | store.dispatch(sortByAmount()); 176 | // store.dispatch(sortByDate()); 177 | 178 | // store.dispatch(setStartDate(0)); // startDate 125 179 | // store.dispatch(setStartDate()); // startDate undefined 180 | // store.dispatch(setEndDate(999)); // endDate 1250 181 | 182 | const demoState = { 183 | expenses: [{ 184 | id: 'poijasdfhwer', 185 | description: 'January Rent', 186 | note: 'This was the final payment for that address', 187 | amount: 54500, 188 | createdAt: 0 189 | }], 190 | filters: { 191 | text: 'rent', 192 | sortBy: 'amount', // date or amount 193 | startDate: undefined, 194 | endDate: undefined 195 | } 196 | }; 197 | -------------------------------------------------------------------------------- /src/reducers/auth.js: -------------------------------------------------------------------------------- 1 | export default (state = {}, action) => { 2 | switch (action.type) { 3 | case 'LOGIN': 4 | return { 5 | uid: action.uid 6 | }; 7 | case 'LOGOUT': 8 | return {}; 9 | default: 10 | return state; 11 | } 12 | }; 13 | -------------------------------------------------------------------------------- /src/reducers/expenses.js: -------------------------------------------------------------------------------- 1 | // Expenses Reducer 2 | 3 | const expensesReducerDefaultState = []; 4 | 5 | export default (state = expensesReducerDefaultState, action) => { 6 | switch (action.type) { 7 | case 'ADD_EXPENSE': 8 | return [ 9 | ...state, 10 | action.expense 11 | ]; 12 | case 'REMOVE_EXPENSE': 13 | return state.filter(({ id }) => id !== action.id); 14 | case 'EDIT_EXPENSE': 15 | return state.map((expense) => { 16 | if (expense.id === action.id) { 17 | return { 18 | ...expense, 19 | ...action.updates 20 | }; 21 | } else { 22 | return expense; 23 | }; 24 | }); 25 | case 'SET_EXPENSES': 26 | return action.expenses; 27 | default: 28 | return state; 29 | } 30 | }; 31 | -------------------------------------------------------------------------------- /src/reducers/filters.js: -------------------------------------------------------------------------------- 1 | import moment from 'moment'; 2 | 3 | // Filters Reducer 4 | 5 | const filtersReducerDefaultState = { 6 | text: '', 7 | sortBy: 'date', 8 | startDate: moment().startOf('month'), 9 | endDate: moment().endOf('month') 10 | }; 11 | 12 | export default (state = filtersReducerDefaultState, action) => { 13 | switch (action.type) { 14 | case 'SET_TEXT_FILTER': 15 | return { 16 | ...state, 17 | text: action.text 18 | }; 19 | case 'SORT_BY_AMOUNT': 20 | return { 21 | ...state, 22 | sortBy: 'amount' 23 | }; 24 | case 'SORT_BY_DATE': 25 | return { 26 | ...state, 27 | sortBy: 'date' 28 | }; 29 | case 'SET_START_DATE': 30 | return { 31 | ...state, 32 | startDate: action.startDate 33 | }; 34 | case 'SET_END_DATE': 35 | return { 36 | ...state, 37 | endDate: action.endDate 38 | }; 39 | default: 40 | return state; 41 | } 42 | }; 43 | -------------------------------------------------------------------------------- /src/routers/AppRouter.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Router, Route, Switch, Link, NavLink } from 'react-router-dom'; 3 | import createHistory from 'history/createBrowserHistory'; 4 | import ExpenseDashboardPage from '../components/ExpenseDashboardPage'; 5 | import AddExpensePage from '../components/AddExpensePage'; 6 | import EditExpensePage from '../components/EditExpensePage'; 7 | import NotFoundPage from '../components/NotFoundPage'; 8 | import LoginPage from '../components/LoginPage'; 9 | import PrivateRoute from './PrivateRoute'; 10 | import PublicRoute from './PublicRoute'; 11 | 12 | export const history = createHistory(); 13 | 14 | const AppRouter = () => ( 15 | 16 |
17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 |
25 |
26 | ); 27 | 28 | export default AppRouter; 29 | -------------------------------------------------------------------------------- /src/routers/PrivateRoute.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { connect } from 'react-redux'; 3 | import { Route, Redirect } from 'react-router-dom'; 4 | import Header from '../components/Header'; 5 | 6 | export const PrivateRoute = ({ 7 | isAuthenticated, 8 | component: Component, 9 | ...rest 10 | }) => ( 11 | ( 12 | isAuthenticated ? ( 13 |
14 |
15 | 16 |
17 | ) : ( 18 | 19 | ) 20 | )} /> 21 | ); 22 | 23 | const mapStateToProps = (state) => ({ 24 | isAuthenticated: !!state.auth.uid 25 | }); 26 | 27 | export default connect(mapStateToProps)(PrivateRoute); 28 | -------------------------------------------------------------------------------- /src/routers/PublicRoute.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { connect } from 'react-redux'; 3 | import { Route, Redirect } from 'react-router-dom'; 4 | 5 | export const PublicRoute = ({ 6 | isAuthenticated, 7 | component: Component, 8 | ...rest 9 | }) => ( 10 | ( 11 | isAuthenticated ? ( 12 | 13 | ) : ( 14 | 15 | ) 16 | )} /> 17 | ); 18 | 19 | const mapStateToProps = (state) => ({ 20 | isAuthenticated: !!state.auth.uid 21 | }); 22 | 23 | export default connect(mapStateToProps)(PublicRoute); 24 | -------------------------------------------------------------------------------- /src/selectors/expenses-total.js: -------------------------------------------------------------------------------- 1 | export default (expenses) => { 2 | return expenses 3 | .map((expense) => expense.amount) 4 | .reduce((sum, value) => sum + value, 0); 5 | }; 6 | -------------------------------------------------------------------------------- /src/selectors/expenses.js: -------------------------------------------------------------------------------- 1 | import moment from 'moment'; 2 | 3 | // Get visible expenses 4 | 5 | export default (expenses, { text, sortBy, startDate, endDate }) => { 6 | return expenses.filter((expense) => { 7 | const createdAtMoment = moment(expense.createdAt); 8 | const startDateMatch = startDate ? startDate.isSameOrBefore(createdAtMoment, 'day') : true; 9 | const endDateMatch = endDate ? endDate.isSameOrAfter(createdAtMoment, 'day') : true; 10 | const textMatch = expense.description.toLowerCase().includes(text.toLowerCase()); 11 | 12 | return startDateMatch && endDateMatch && textMatch; 13 | }).sort((a, b) => { 14 | if (sortBy === 'date') { 15 | return a.createdAt < b.createdAt ? 1 : -1; 16 | } else if (sortBy === 'amount') { 17 | return a.amount < b.amount ? 1 : -1; 18 | } 19 | }); 20 | }; 21 | -------------------------------------------------------------------------------- /src/store/configureStore.js: -------------------------------------------------------------------------------- 1 | import { createStore, combineReducers, applyMiddleware, compose } from 'redux'; 2 | import thunk from 'redux-thunk'; 3 | import expensesReducer from '../reducers/expenses'; 4 | import filtersReducer from '../reducers/filters'; 5 | import authReducer from '../reducers/auth'; 6 | 7 | const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; 8 | 9 | export default () => { 10 | const store = createStore( 11 | combineReducers({ 12 | expenses: expensesReducer, 13 | filters: filtersReducer, 14 | auth: authReducer 15 | }), 16 | composeEnhancers(applyMiddleware(thunk)) 17 | ); 18 | 19 | return store; 20 | }; 21 | -------------------------------------------------------------------------------- /src/styles/base/_base.scss: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | } 4 | 5 | html { 6 | font-size: 62.5%; 7 | } 8 | 9 | body { 10 | color: $dark-grey; 11 | font-family: Helvetica, Arial, sans-serif; 12 | font-size: $m-size; 13 | line-height: 1.6; 14 | } 15 | 16 | button { 17 | cursor: pointer; 18 | } 19 | 20 | button:disabled { 21 | cursor: default; 22 | } 23 | 24 | .is-active { 25 | font-weight: bold; 26 | } 27 | -------------------------------------------------------------------------------- /src/styles/base/_settings.scss: -------------------------------------------------------------------------------- 1 | // Colors 2 | $dark-grey: #333; 3 | $grey: #666; 4 | $blue: #1c88bf; 5 | $dark-blue: #364051; 6 | $off-white: #f7f7f7; 7 | // Font Size 8 | $font-size-large: 1.8rem; 9 | $font-size-small: 1.4rem; 10 | // Spacing 11 | $s-size: 1.2rem; 12 | $m-size: 1.6rem; 13 | $l-size: 3.2rem; 14 | $xl-size: 4.8rem; 15 | $desktop-breakpoint: 45rem; 16 | -------------------------------------------------------------------------------- /src/styles/components/_box-layout.scss: -------------------------------------------------------------------------------- 1 | .box-layout { 2 | align-items: center; 3 | background: url('/images/bg.jpg'); 4 | background-size: cover; 5 | display: flex; 6 | height: 100vh; 7 | justify-content: center; 8 | width: 100vw; 9 | } 10 | 11 | .box-layout__box { 12 | background: fade-out(white, .15); 13 | border-radius: 3px; 14 | padding: $l-size $m-size; 15 | text-align: center; 16 | width: 25rem; 17 | } 18 | 19 | .box-layout__title { 20 | margin: 0 0 $m-size 0; 21 | line-height: 1; 22 | } 23 | -------------------------------------------------------------------------------- /src/styles/components/_button.scss: -------------------------------------------------------------------------------- 1 | .button { 2 | background: $blue; 3 | border: none; 4 | color: white; 5 | display: inline-block; 6 | font-size: $font-size-large; 7 | font-weight: 300; 8 | line-height: 1; 9 | padding: $s-size; 10 | text-decoration: none; 11 | } 12 | 13 | .button--link { 14 | background: none; 15 | } 16 | 17 | .button--secondary { 18 | background: #888; 19 | } 20 | -------------------------------------------------------------------------------- /src/styles/components/_content-container.scss: -------------------------------------------------------------------------------- 1 | .content-container { 2 | margin: 0 auto; 3 | padding: 0 $m-size; 4 | max-width: 80rem; 5 | } 6 | -------------------------------------------------------------------------------- /src/styles/components/_form.scss: -------------------------------------------------------------------------------- 1 | .form { 2 | display: flex; 3 | flex-direction: column; 4 | >* { 5 | margin-bottom: $m-size; 6 | } 7 | } 8 | 9 | .form__error { 10 | margin: 0 0 $m-size 0; 11 | font-style: italic; 12 | } 13 | -------------------------------------------------------------------------------- /src/styles/components/_header.scss: -------------------------------------------------------------------------------- 1 | .header { 2 | background: $dark-blue; 3 | } 4 | 5 | .header__content { 6 | align-items: center; 7 | display: flex; 8 | justify-content: space-between; 9 | padding: $s-size 0; 10 | } 11 | 12 | .header__title { 13 | color: white; 14 | text-decoration: none; 15 | h1 { 16 | margin: 0; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/styles/components/_input-group.scss: -------------------------------------------------------------------------------- 1 | .input-group { 2 | display: flex; 3 | flex-direction: column; 4 | margin-bottom: $m-size; 5 | @media (min-width: $desktop-breakpoint) { 6 | flex-direction: row; 7 | margin-bottom: $l-size; 8 | } 9 | } 10 | 11 | .input-group__item { 12 | margin-bottom: $s-size; 13 | @media (min-width: $desktop-breakpoint) { 14 | margin: 0 $s-size 0 0; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/styles/components/_inputs.scss: -------------------------------------------------------------------------------- 1 | .text-input { 2 | border: 1px solid #cacccd; 3 | height: 50px; 4 | font-size: $font-size-large; 5 | font-weight: 300; 6 | padding: $s-size; 7 | } 8 | 9 | .select { 10 | @extend .text-input; 11 | } 12 | 13 | .textarea { 14 | @extend .text-input; 15 | height: 10rem; 16 | } 17 | -------------------------------------------------------------------------------- /src/styles/components/_list.scss: -------------------------------------------------------------------------------- 1 | .list-header { 2 | background: $off-white; 3 | border: 1px solid darken($off-white, 7%); 4 | color: $grey; 5 | display: flex; 6 | justify-content: space-between; 7 | padding: $s-size $m-size; 8 | } 9 | 10 | .list-body { 11 | margin-bottom: $m-size; 12 | @media (min-width: $desktop-breakpoint) { 13 | margin-bottom: $l-size; 14 | } 15 | } 16 | 17 | .list-item { 18 | border: 1px solid darken($off-white, 7%); 19 | border-top: none; 20 | color: $dark-grey; 21 | display: flex; 22 | flex-direction: column; 23 | padding: $s-size; 24 | text-decoration: none; 25 | transition: background .3s ease; 26 | &:hover { 27 | background: $off-white; 28 | } 29 | @media (min-width: $desktop-breakpoint) { 30 | align-items: center; 31 | flex-direction: row; 32 | justify-content: space-between; 33 | padding: $m-size; 34 | } 35 | } 36 | 37 | .list-item--message { 38 | align-items: center; 39 | color: $grey; 40 | justify-content: center; 41 | padding: $m-size; 42 | &:hover { 43 | background: none; 44 | } 45 | } 46 | 47 | .list-item__title { 48 | margin: 0; 49 | word-break: break-all; 50 | } 51 | 52 | .list-item__sub-title { 53 | color: $grey; 54 | font-size: $font-size-small; 55 | } 56 | 57 | .list-item__data { 58 | margin: $s-size 0 0 0; 59 | @media (min-width: $desktop-breakpoint) { 60 | margin: 0; 61 | padding-left: $s-size; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/styles/components/_loader.scss: -------------------------------------------------------------------------------- 1 | .loader { 2 | align-items: center; 3 | display: flex; 4 | height: 100vh; 5 | justify-content: center; 6 | width: 100vw; 7 | } 8 | 9 | .loader__image { 10 | height: 6rem; 11 | width: 6rem; 12 | } 13 | -------------------------------------------------------------------------------- /src/styles/components/_page-header.scss: -------------------------------------------------------------------------------- 1 | .page-header { 2 | background: $off-white; 3 | margin-bottom: $l-size; 4 | padding: $l-size 0; 5 | } 6 | 7 | .page-header__actions { 8 | margin-top: $m-size; 9 | } 10 | 11 | .page-header__title { 12 | font-weight: 300; 13 | margin: 0; 14 | span { 15 | font-weight: 700; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/styles/components/_visibility.scss: -------------------------------------------------------------------------------- 1 | .show-for-mobile { 2 | @media (min-width: $desktop-breakpoint) { 3 | display: none; 4 | } 5 | } 6 | 7 | .show-for-desktop { 8 | @media (max-width: $desktop-breakpoint - .01rem) { 9 | display: none; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/styles/styles.scss: -------------------------------------------------------------------------------- 1 | @import './base/settings'; 2 | @import './base/base'; 3 | @import './components/box-layout'; 4 | @import './components/button'; 5 | @import './components/header'; 6 | @import './components/content-container'; 7 | @import './components/page-header'; 8 | @import './components/input-group'; 9 | @import './components/inputs'; 10 | @import './components/form'; 11 | @import './components/visibility'; 12 | @import './components/list'; 13 | @import './components/loader'; 14 | -------------------------------------------------------------------------------- /src/tests/__mocks__/moment.js: -------------------------------------------------------------------------------- 1 | const moment = require.requireActual('moment'); 2 | 3 | export default (timestamp = 0) => { 4 | return moment(timestamp); 5 | }; 6 | -------------------------------------------------------------------------------- /src/tests/actions/auth.test.js: -------------------------------------------------------------------------------- 1 | import { login, logout } from '../../actions/auth'; 2 | 3 | test('should generate login action object', () => { 4 | const uid = 'abc123'; 5 | const action = login(uid); 6 | expect(action).toEqual({ 7 | type: 'LOGIN', 8 | uid 9 | }); 10 | }); 11 | 12 | test('should generate logout action object', () => { 13 | const action = logout(); 14 | expect(action).toEqual({ 15 | type: 'LOGOUT' 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /src/tests/actions/expenses.test.js: -------------------------------------------------------------------------------- 1 | import configureMockStore from 'redux-mock-store'; 2 | import thunk from 'redux-thunk'; 3 | import { 4 | startAddExpense, 5 | addExpense, 6 | editExpense, 7 | startEditExpense, 8 | removeExpense, 9 | startRemoveExpense, 10 | setExpenses, 11 | startSetExpenses 12 | } from '../../actions/expenses'; 13 | import expenses from '../fixtures/expenses'; 14 | import database from '../../firebase/firebase'; 15 | 16 | const uid = 'thisismytestuid'; 17 | const defaultAuthState = { auth: { uid } }; 18 | const createMockStore = configureMockStore([thunk]); 19 | 20 | beforeEach((done) => { 21 | const expensesData = {}; 22 | expenses.forEach(({ id, description, note, amount, createdAt }) => { 23 | expensesData[id] = { description, note, amount, createdAt }; 24 | }); 25 | database.ref(`users/${uid}/expenses`).set(expensesData).then(() => done()); 26 | }); 27 | 28 | test('should setup remove expense action object', () => { 29 | const action = removeExpense({ id: '123abc' }); 30 | expect(action).toEqual({ 31 | type: 'REMOVE_EXPENSE', 32 | id: '123abc' 33 | }); 34 | }); 35 | 36 | test('should remove expense from firebase', (done) => { 37 | const store = createMockStore(defaultAuthState); 38 | const id = expenses[2].id; 39 | store.dispatch(startRemoveExpense({ id })).then(() => { 40 | const actions = store.getActions(); 41 | expect(actions[0]).toEqual({ 42 | type: 'REMOVE_EXPENSE', 43 | id 44 | }); 45 | return database.ref(`users/${uid}/expenses/${id}`).once('value'); 46 | }).then((snapshot) => { 47 | expect(snapshot.val()).toBeFalsy(); 48 | done(); 49 | }); 50 | }); 51 | 52 | test('should setup edit expense action object', () => { 53 | const action = editExpense('123abc', { note: 'New note value' }); 54 | expect(action).toEqual({ 55 | type: 'EDIT_EXPENSE', 56 | id: '123abc', 57 | updates: { 58 | note: 'New note value' 59 | } 60 | }); 61 | }); 62 | 63 | test('should edit expense from firebase', (done) => { 64 | const store = createMockStore(defaultAuthState); 65 | const id = expenses[0].id; 66 | const updates = { amount: 21045 }; 67 | store.dispatch(startEditExpense(id, updates)).then(() => { 68 | const actions = store.getActions(); 69 | expect(actions[0]).toEqual({ 70 | type: 'EDIT_EXPENSE', 71 | id, 72 | updates 73 | }); 74 | return database.ref(`users/${uid}/expenses/${id}`).once('value'); 75 | }).then((snapshot) => { 76 | expect(snapshot.val().amount).toBe(updates.amount); 77 | done(); 78 | }); 79 | }); 80 | 81 | test('should setup add expense action object with provided values', () => { 82 | const action = addExpense(expenses[2]); 83 | expect(action).toEqual({ 84 | type: 'ADD_EXPENSE', 85 | expense: expenses[2] 86 | }); 87 | }); 88 | 89 | test('should add expense to database and store', (done) => { 90 | const store = createMockStore(defaultAuthState); 91 | const expenseData = { 92 | description: 'Mouse', 93 | amount: 3000, 94 | note: 'This one is better', 95 | createdAt: 1000 96 | }; 97 | 98 | store.dispatch(startAddExpense(expenseData)).then(() => { 99 | const actions = store.getActions(); 100 | expect(actions[0]).toEqual({ 101 | type: 'ADD_EXPENSE', 102 | expense: { 103 | id: expect.any(String), 104 | ...expenseData 105 | } 106 | }); 107 | 108 | return database.ref(`users/${uid}/expenses/${actions[0].expense.id}`).once('value'); 109 | }).then((snapshot) => { 110 | expect(snapshot.val()).toEqual(expenseData); 111 | done(); 112 | }); 113 | }); 114 | 115 | test('should add expense with defaults to database and store', (done) => { 116 | const store = createMockStore(defaultAuthState); 117 | const expenseDefaults = { 118 | description: '', 119 | amount: 0, 120 | note: '', 121 | createdAt: 0 122 | }; 123 | 124 | store.dispatch(startAddExpense({})).then(() => { 125 | const actions = store.getActions(); 126 | expect(actions[0]).toEqual({ 127 | type: 'ADD_EXPENSE', 128 | expense: { 129 | id: expect.any(String), 130 | ...expenseDefaults 131 | } 132 | }); 133 | 134 | return database.ref(`users/${uid}/expenses/${actions[0].expense.id}`).once('value'); 135 | }).then((snapshot) => { 136 | expect(snapshot.val()).toEqual(expenseDefaults); 137 | done(); 138 | }); 139 | }); 140 | 141 | test('should setup set expense action object with data', () => { 142 | const action = setExpenses(expenses); 143 | expect(action).toEqual({ 144 | type: 'SET_EXPENSES', 145 | expenses 146 | }); 147 | }); 148 | 149 | test('should fetch the expenses from firebase', (done) => { 150 | const store = createMockStore(defaultAuthState); 151 | store.dispatch(startSetExpenses()).then(() => { 152 | const actions = store.getActions(); 153 | expect(actions[0]).toEqual({ 154 | type: 'SET_EXPENSES', 155 | expenses 156 | }); 157 | done(); 158 | }); 159 | }); 160 | -------------------------------------------------------------------------------- /src/tests/actions/filters.test.js: -------------------------------------------------------------------------------- 1 | import moment from 'moment'; 2 | import { 3 | setStartDate, 4 | setEndDate, 5 | setTextFilter, 6 | sortByAmount, 7 | sortByDate 8 | } from '../../actions/filters'; 9 | 10 | test('should generate set start date action object', () => { 11 | const action = setStartDate(moment(0)); 12 | expect(action).toEqual({ 13 | type: 'SET_START_DATE', 14 | startDate: moment(0) 15 | }); 16 | }); 17 | 18 | test('should generate set end date aciton object', () => { 19 | const action = setEndDate(moment(0)); 20 | expect(action).toEqual({ 21 | type: 'SET_END_DATE', 22 | endDate: moment(0) 23 | }); 24 | }); 25 | 26 | test('should generate set text filter object with text value', () => { 27 | const text = 'Something in'; 28 | const action = setTextFilter(text); 29 | expect(action).toEqual({ 30 | type: 'SET_TEXT_FILTER', 31 | text 32 | }); 33 | }); 34 | 35 | test('should generate set text filter object with default', () => { 36 | const action = setTextFilter(); 37 | expect(action).toEqual({ 38 | type: 'SET_TEXT_FILTER', 39 | text: '' 40 | }); 41 | }); 42 | 43 | test('should generate action object for sort by date', () => { 44 | expect(sortByDate()).toEqual({ type: 'SORT_BY_DATE' }); 45 | }); 46 | 47 | test('should generate action object for sort by amount', () => { 48 | expect(sortByAmount()).toEqual({ type: 'SORT_BY_AMOUNT' }); 49 | }); 50 | -------------------------------------------------------------------------------- /src/tests/components/AddExpensePage.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | import { AddExpensePage } from '../../components/AddExpensePage'; 4 | import expenses from '../fixtures/expenses'; 5 | 6 | let startAddExpense, history, wrapper; 7 | 8 | beforeEach(() => { 9 | startAddExpense = jest.fn(); 10 | history = { push: jest.fn() }; 11 | wrapper = shallow(); 12 | }); 13 | 14 | test('should render AddExpensePage correctly', () => { 15 | expect(wrapper).toMatchSnapshot(); 16 | }); 17 | 18 | test('should handle onSubmit', () => { 19 | wrapper.find('ExpenseForm').prop('onSubmit')(expenses[1]); 20 | expect(history.push).toHaveBeenLastCalledWith('/'); 21 | expect(startAddExpense).toHaveBeenLastCalledWith(expenses[1]); 22 | }); 23 | -------------------------------------------------------------------------------- /src/tests/components/EditExpensePage.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | import expenses from '../fixtures/expenses'; 4 | import { EditExpensePage } from '../../components/EditExpensePage'; 5 | 6 | let startEditExpense, startRemoveExpense, history, wrapper; 7 | 8 | beforeEach(() => { 9 | startEditExpense = jest.fn(); 10 | startRemoveExpense = jest.fn(); 11 | history = { push: jest.fn() }; 12 | wrapper = shallow( 13 | 19 | ); 20 | }); 21 | 22 | test('should render EditExpensePage', () => { 23 | expect(wrapper).toMatchSnapshot(); 24 | }); 25 | 26 | test('should handle startEditExpense', () => { 27 | wrapper.find('ExpenseForm').prop('onSubmit')(expenses[2]); 28 | expect(history.push).toHaveBeenLastCalledWith('/'); 29 | expect(startEditExpense).toHaveBeenLastCalledWith(expenses[2].id, expenses[2]); 30 | }); 31 | 32 | test('should handle startRemoveExpense', () => { 33 | wrapper.find('button').simulate('click'); 34 | expect(history.push).toHaveBeenLastCalledWith('/'); 35 | expect(startRemoveExpense).toHaveBeenLastCalledWith({ 36 | id: expenses[2].id 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /src/tests/components/ExpenseDashboardPage.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | import ExpenseDashboardPage from '../../components/ExpenseDashboardPage'; 4 | 5 | test('should render ExpenseDashboardPage correctly', () => { 6 | const wrapper = shallow(); 7 | expect(wrapper).toMatchSnapshot(); 8 | }); 9 | -------------------------------------------------------------------------------- /src/tests/components/ExpenseForm.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | import moment from 'moment'; 4 | import ExpenseForm from '../../components/ExpenseForm'; 5 | import expenses from '../fixtures/expenses'; 6 | 7 | test('should render ExpenseForm correctly', () => { 8 | const wrapper = shallow(); 9 | expect(wrapper).toMatchSnapshot(); 10 | }); 11 | 12 | test('should render ExpenseForm correctly with expense data', () => { 13 | const wrapper = shallow(); 14 | expect(wrapper).toMatchSnapshot(); 15 | }); 16 | 17 | test('should render error for invalid form submission', () => { 18 | const wrapper = shallow(); 19 | expect(wrapper).toMatchSnapshot(); 20 | wrapper.find('form').simulate('submit', { 21 | preventDefault: () => { } 22 | }); 23 | expect(wrapper.state('error').length).toBeGreaterThan(0); 24 | expect(wrapper).toMatchSnapshot(); 25 | }); 26 | 27 | test('should set description on input change', () => { 28 | const value = 'New description'; 29 | const wrapper = shallow(); 30 | wrapper.find('input').at(0).simulate('change', { 31 | target: { value } 32 | }); 33 | expect(wrapper.state('description')).toBe(value); 34 | }); 35 | 36 | test('should set note on textarea change', () => { 37 | const value = 'New note value'; 38 | const wrapper = shallow(); 39 | wrapper.find('textarea').simulate('change', { 40 | target: { value } 41 | }); 42 | expect(wrapper.state('note')).toBe(value); 43 | }); 44 | 45 | test('should set amount if valid input', () => { 46 | const value = '23.50'; 47 | const wrapper = shallow(); 48 | wrapper.find('input').at(1).simulate('change', { 49 | target: { value } 50 | }); 51 | expect(wrapper.state('amount')).toBe(value); 52 | }); 53 | 54 | test('should not set amount if invalid input', () => { 55 | const value = '12.122'; 56 | const wrapper = shallow(); 57 | wrapper.find('input').at(1).simulate('change', { 58 | target: { value } 59 | }); 60 | expect(wrapper.state('amount')).toBe(''); 61 | }); 62 | 63 | test('should call onSubmit prop for valid form submission', () => { 64 | const onSubmitSpy = jest.fn(); 65 | const wrapper = shallow(); 66 | wrapper.find('form').simulate('submit', { 67 | preventDefault: () => { } 68 | }); 69 | expect(wrapper.state('error')).toBe(''); 70 | expect(onSubmitSpy).toHaveBeenLastCalledWith({ 71 | description: expenses[0].description, 72 | amount: expenses[0].amount, 73 | note: expenses[0].note, 74 | createdAt: expenses[0].createdAt 75 | }); 76 | }); 77 | 78 | test('should set new date on date change', () => { 79 | const now = moment(); 80 | const wrapper = shallow(); 81 | wrapper.find('SingleDatePicker').prop('onDateChange')(now); 82 | expect(wrapper.state('createdAt')).toEqual(now); 83 | }); 84 | 85 | test('should set calendar focus on change', () => { 86 | const focused = true; 87 | const wrapper = shallow(); 88 | wrapper.find('SingleDatePicker').prop('onFocusChange')({ focused }); 89 | expect(wrapper.state('calendarFocused')).toBe(focused); 90 | }); 91 | -------------------------------------------------------------------------------- /src/tests/components/ExpenseList.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | import { ExpenseList } from '../../components/ExpenseList'; 4 | import expenses from '../fixtures/expenses'; 5 | 6 | test('should render ExpenseList with expenses', () => { 7 | const wrapper = shallow(); 8 | expect(wrapper).toMatchSnapshot(); 9 | }); 10 | 11 | test('should render ExpenseList with empty message', () => { 12 | const wrapper = shallow(); 13 | expect(wrapper).toMatchSnapshot(); 14 | }); 15 | -------------------------------------------------------------------------------- /src/tests/components/ExpenseListFilters.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | import moment from 'moment'; 4 | import { ExpenseListFilters } from '../../components/ExpenseListFilters'; 5 | import { filters, altFilters } from '../fixtures/filters'; 6 | 7 | let setTextFilter, sortByDate, sortByAmount, setStartDate, setEndDate, wrapper; 8 | 9 | beforeEach(() => { 10 | setTextFilter = jest.fn(); 11 | sortByDate = jest.fn(); 12 | sortByAmount = jest.fn(); 13 | setStartDate = jest.fn(); 14 | setEndDate = jest.fn(); 15 | wrapper = shallow( 16 | 24 | ); 25 | }); 26 | 27 | test('should render ExpenseListFilters correctly', () => { 28 | expect(wrapper).toMatchSnapshot(); 29 | }); 30 | 31 | test('should render ExpenseListFilters with alt data correctly', () => { 32 | wrapper.setProps({ 33 | filters: altFilters 34 | }); 35 | expect(wrapper).toMatchSnapshot(); 36 | }); 37 | 38 | test('should handle text change', () => { 39 | const value = 'rent'; 40 | wrapper.find('input').simulate('change', { 41 | target: { value } 42 | }); 43 | expect(setTextFilter).toHaveBeenLastCalledWith(value); 44 | }); 45 | 46 | test('should sort by date', () => { 47 | const value = 'date'; 48 | wrapper.setProps({ 49 | filters: altFilters 50 | }); 51 | wrapper.find('select').simulate('change', { 52 | target: { value } 53 | }); 54 | expect(sortByDate).toHaveBeenCalled(); 55 | }); 56 | 57 | test('should sort by amount', () => { 58 | const value = 'amount'; 59 | wrapper.find('select').simulate('change', { 60 | target: { value } 61 | }); 62 | expect(sortByAmount).toHaveBeenCalled(); 63 | }); 64 | 65 | test('should handle date changes', () => { 66 | const startDate = moment(0).add(4, 'years'); 67 | const endDate = moment(0).add(8, 'years'); 68 | wrapper.find('DateRangePicker').prop('onDatesChange')({ startDate, endDate }); 69 | expect(setStartDate).toHaveBeenLastCalledWith(startDate); 70 | expect(setEndDate).toHaveBeenLastCalledWith(endDate); 71 | }); 72 | 73 | test('hould handle date focus changes', () => { 74 | const calendarFocused = 'endDate'; 75 | wrapper.find('DateRangePicker').prop('onFocusChange')(calendarFocused); 76 | expect(wrapper.state('calendarFocused')).toBe(calendarFocused); 77 | }); 78 | -------------------------------------------------------------------------------- /src/tests/components/ExpenseListItem.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | import expenses from '../fixtures/expenses'; 4 | import ExpenseListItem from '../../components/ExpenseListItem'; 5 | 6 | test('should render ExpenseListItem correctly', () => { 7 | const wrapper = shallow(); 8 | expect(wrapper).toMatchSnapshot(); 9 | }); 10 | -------------------------------------------------------------------------------- /src/tests/components/ExpensesSummary.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | import { ExpensesSummary } from '../../components/ExpensesSummary'; 4 | 5 | test('should correctly render ExpensesSummary with 1 expense', () => { 6 | const wrapper = shallow(); 7 | expect(wrapper).toMatchSnapshot(); 8 | }); 9 | 10 | test('should correctly render ExpensesSummary with multiple expenses', () => { 11 | const wrapper = shallow(); 12 | expect(wrapper).toMatchSnapshot(); 13 | }); 14 | -------------------------------------------------------------------------------- /src/tests/components/Header.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | import { Header } from '../../components/Header'; 4 | 5 | test('should render Header correctly', () => { 6 | const wrapper = shallow(
{ }} />); 7 | expect(wrapper).toMatchSnapshot(); 8 | }); 9 | 10 | test('should call startLogout on button click', () => { 11 | const startLogout = jest.fn(); 12 | const wrapper = shallow(
); 13 | wrapper.find('button').simulate('click'); 14 | expect(startLogout).toHaveBeenCalled(); 15 | }); 16 | -------------------------------------------------------------------------------- /src/tests/components/LoadingPage.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | import LoadingPage from '../../components/LoadingPage'; 4 | 5 | test('should correctly render LoadingPage', () => { 6 | const wrapper = shallow(); 7 | expect(wrapper).toMatchSnapshot(); 8 | }); 9 | -------------------------------------------------------------------------------- /src/tests/components/LoginPage.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | import { LoginPage } from '../../components/LoginPage'; 4 | 5 | test('should correctly render LoginPage', () => { 6 | const wrapper = shallow(); 7 | expect(wrapper).toMatchSnapshot(); 8 | }); 9 | 10 | test('should call startLogin on button click', () => { 11 | const startLogin = jest.fn(); 12 | const wrapper = shallow(); 13 | wrapper.find('button').simulate('click'); 14 | expect(startLogin).toHaveBeenCalled(); 15 | }); 16 | -------------------------------------------------------------------------------- /src/tests/components/NotFoundPage.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | import NotFoundPage from '../../components/NotFoundPage'; 4 | 5 | test('should render NotFoundPage correctly', () => { 6 | const wrapper = shallow(); 7 | expect(wrapper).toMatchSnapshot(); 8 | }); 9 | -------------------------------------------------------------------------------- /src/tests/components/__snapshots__/AddExpensePage.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`should render AddExpensePage correctly 1`] = ` 4 |
5 |
8 |
11 |

14 | Add Expense 15 |

16 |
17 |
18 |
21 | 24 |
25 |
26 | `; 27 | -------------------------------------------------------------------------------- /src/tests/components/__snapshots__/EditExpensePage.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`should render EditExpensePage 1`] = ` 4 |
5 |
8 |
11 |

14 | Edit Expense 15 |

16 |
17 |
18 |
21 | 33 | 39 |
40 |
41 | `; 42 | -------------------------------------------------------------------------------- /src/tests/components/__snapshots__/ExpenseDashboardPage.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`should render ExpenseDashboardPage correctly 1`] = ` 4 |
5 | 6 | 7 | 8 |
9 | `; 10 | -------------------------------------------------------------------------------- /src/tests/components/__snapshots__/ExpenseForm.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`should render ExpenseForm correctly 1`] = ` 4 |
8 | 16 | 23 | 95 |