diff --git a/app.js b/app.js index 33651bb..aaf54f7 100644 --- a/app.js +++ b/app.js @@ -21,6 +21,7 @@ mongoose.connection.on('error', (err) => { const usersRouter = require('./routes/users'); const expensesRouter = require('./routes/expenses'); +const groupsRouter = require('./routes/groups'); let app = express(); @@ -31,5 +32,6 @@ app.use(express.json()); app.use('/users', usersRouter); app.use('/expenses', expensesRouter.router); +app.use('/groups', groupsRouter.router); -app.listen(5000); \ No newline at end of file +app.listen(5000); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 6007a1d..9cb223c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -7569,6 +7569,11 @@ "resolved": "https://registry.npmjs.org/immer/-/immer-7.0.9.tgz", "integrity": "sha512-Vs/gxoM4DqNAYR7pugIxi0Xc8XAun/uy7AQu4fLLqaTBHxjOP9pJ266Q9MWA/ly4z6rAFZbvViOtihxUZ7O28A==" }, + "immutability-helper": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/immutability-helper/-/immutability-helper-3.1.1.tgz", + "integrity": "sha512-Q0QaXjPjwIju/28TsugCHNEASwoCcJSyJV3uO1sOIQGI0jKgm9f41Lvz0DZj3n46cNCyAZTsEYoY4C2bVRUzyQ==" + }, "import-cwd": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/import-cwd/-/import-cwd-2.1.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index e5ed9fa..f89551d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,6 +13,7 @@ "apexcharts": "^3.23.1", "axios": "^0.21.1", "bootstrap": "^4.5.3", + "immutability-helper": "^3.1.1", "moment": "^2.29.1", "querystring": "^0.2.0", "react": "^17.0.1", diff --git a/frontend/public/styles.css b/frontend/public/styles.css index 29c1e48..e9f9e72 100644 --- a/frontend/public/styles.css +++ b/frontend/public/styles.css @@ -15,15 +15,18 @@ html { input[type='date']::-webkit-calendar-picker-indicator { position: absolute; top: 0; - left: 0; right: 0; - bottom: 0; - width: auto; - height: auto; + width: 100%; + height: 100%; color: transparent; + border: 0; background: transparent; } +input[type='date']::-webkit-calendar-picker-indicator:active { + border: 0; +} + /* Hide number input spinners */ /* Chrome, Safari, Edge, Opera */ input::-webkit-outer-spin-button, diff --git a/frontend/src/App.js b/frontend/src/App.js index e36b3f7..f562183 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -1,5 +1,5 @@ import React, { Component } from 'react'; -import { BrowserRouter as Router, Route } from 'react-router-dom'; +import { BrowserRouter as Router, Route, Redirect } from 'react-router-dom'; import NavBar from './components/navBar'; import LandingPage from './containers/home/landingPage'; @@ -7,6 +7,7 @@ import Terms from './containers/home/terms'; import Login from './containers/users/login'; import Register from './containers/users/register'; import Dashboard from './containers/expenses/dashboard'; +import Expenses from './containers/expenses/expenses'; class App extends Component { render() { @@ -20,8 +21,10 @@ class App extends Component { + + } /> ); diff --git a/frontend/src/components/customInputSelect.jsx b/frontend/src/components/customInputSelect.jsx index 2b95f96..236ebf9 100644 --- a/frontend/src/components/customInputSelect.jsx +++ b/frontend/src/components/customInputSelect.jsx @@ -42,15 +42,16 @@ class CustomInputSelect extends Component { } } - getSuggestions = () => { - const escapedValue = escapeRegexCharacters(String(this.state.value).trim()); + getSuggestions = (value) => { + const escapedValue = escapeRegexCharacters(String(value).trim()); const regex = new RegExp('^' + escapedValue, 'i'); const suggestions = this.props.options.filter((option) => regex.test(option) ); if (suggestions.length > 0) return suggestions; - else return [{ isAddNew: true }]; + else if (this.state.value) return [{ isAddNew: true }]; + else return []; }; getSuggestionValue = (suggestion) => { @@ -79,7 +80,7 @@ class CustomInputSelect extends Component { onClear = (e) => { const input = e.currentTarget.parentNode.getElementsByTagName('input')[0]; this.setState({ value: '', suggestions: this.props.options }, () => { - input.focus() + input.focus(); }); }; diff --git a/frontend/src/components/expensesTable.css b/frontend/src/components/expensesTable.css index 16bc3df..90355cd 100644 --- a/frontend/src/components/expensesTable.css +++ b/frontend/src/components/expensesTable.css @@ -1,9 +1,9 @@ -table { +.expenses-table table { table-layout: fixed; z-index: 1; } -td span { +.expenses-table td span { display: block; white-space: nowrap; overflow: hidden; @@ -14,22 +14,30 @@ td span { width: 4rem; } -table thead th { +.expenses-table table thead th { position: sticky; top: 0; background-color: white; z-index: 3; } -td svg, -table thead th span { +.expenses-table td svg, +.expenses-table table thead th span { cursor: pointer; } -tbody tr:hover svg { +.expenses-table tbody tr:hover svg { visibility: visible; } +.expenses-table tbody tr:hover svg:hover { + opacity: 50%; +} + +.expenses-table tbody tr:hover td:not(.expense-action) { + cursor: pointer; +} + .expense-normal, .expense-confirm { display: flex; @@ -50,10 +58,19 @@ tbody tr:hover svg { background-color: rgb(255, 93, 93); } -tbody tr:hover:not(.expense-delete) { +.expense-edit { + background-color: rgb(235, 192, 0); +} + +.expenses-table tbody tr:hover:not(.expense-delete), +.expenses-table .expense-select { background-color: lightgray; } -table tbody tr td { +.expenses-table table tbody tr td { position: relative; } + +.expenses-table .fade { + transition: 0; +} diff --git a/frontend/src/components/expensesTable.jsx b/frontend/src/components/expensesTable.jsx index a3e1bb9..3c76e62 100644 --- a/frontend/src/components/expensesTable.jsx +++ b/frontend/src/components/expensesTable.jsx @@ -10,7 +10,18 @@ import moment from 'moment'; import { animateScroll } from 'react-scroll'; import './expensesTable.css'; -import { Table, Card, Row, Col, FormControl, Form } from 'react-bootstrap'; +import { + Table, + Card, + Row, + Col, + FormControl, + Form, + OverlayTrigger, + Popover, + Modal, + Button, +} from 'react-bootstrap'; import CustomInputSelect from './customInputSelect'; import CustomPagination from './customPagination'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; @@ -20,10 +31,18 @@ import { faSortUp, faSort, faTrash, - faTimes, - faCheck, } from '@fortawesome/free-solid-svg-icons'; +const emptyExpense = { + _id: null, + title: null, + amount: null, + category: null, + date: null, + group_id: null, + description: null, +}; + class ExpensesTable extends Component { constructor(props) { super(props); @@ -33,20 +52,27 @@ class ExpensesTable extends Component { sort: 'date', perPage: 100, currentPage: 1, - editExpense: { - id: null, - title: null, - amount: null, - category: null, - date: null, - }, - deleteExpenseId: null, + editExpense: emptyExpense, + selectExpense: emptyExpense, + deleteExpense: emptyExpense, search: '', update: this.props.update, + prevProps: null, }; + + this.wrapperRef = React.createRef(); + this.onClickOutside = this.onClickOutside.bind(this); + } + componentDidMount() { + this.fetchExpenses(); + document.addEventListener('mousedown', this.onClickOutside); } - componentDidUpdate() { + componentWillUnmount() { + document.removeEventListener('mousedown', this.onClickOutside); + } + + componentDidUpdate(props, state) { if (this.state.update !== this.props.update) { this.setState({ update: this.props.update }); @@ -58,6 +84,32 @@ class ExpensesTable extends Component { } } + onClickOutside(event) { + if (this.wrapperRef && !this.wrapperRef.current.contains(event.target)) { + this.resetSelectExpense(); + } + } + + getExpenseAttributes = (e) => { + const expense = e.currentTarget.closest('tr'); + let origVals = { + _id: expense.getAttribute('_id'), + title: expense.getAttribute('title'), + amount: expense.getAttribute('amount'), + category: expense.getAttribute('category'), + date: expense.getAttribute('date'), + group_id: + expense.getAttribute('group_id') || + (this.props.groups && this.props.groups.length > 0 + ? this.props.groups[ + this.props.groups.findIndex((obj) => obj.default === true) + ]._id + : ''), + description: expense.getAttribute('description') || '', + }; + return origVals; + }; + /** * Function to update redux stored expenses * Used to update the table @@ -94,9 +146,9 @@ class ExpensesTable extends Component { const year = moment(this.props.year, 'YYYY'); query.start_date = year.clone().startOf('year').format('YYYY/MM/DD'); query.end_date = year.clone().endOf('year').format('YYYY/MM/DD'); - console.log(query.start_date + ' ' + query.end_date); } } + query = { ...query, ...this.props.options }; this.props.getUserExpenses(query).then(() => { if (scrollToTop) animateScroll.scrollToTop({ containerId: 'table', duration: 0 }); @@ -104,21 +156,27 @@ class ExpensesTable extends Component { }; resetEditExpense = () => { - this.setState({ - editExpense: { - id: null, - title: null, - amount: null, - category: null, - date: null, - }, - }); + this.setState({ editExpense: emptyExpense }); }; resetDeleteExpense = () => { - this.setState({ - deleteExpenseId: null, - }); + this.setState({ deleteExpense: emptyExpense }); + }; + + resetSelectExpense = () => { + this.setState({ selectExpense: emptyExpense }); + }; + + getGroupName = (group_id) => { + if (group_id && this.props.groups && this.props.groups.length > 0) { + const group_index = this.props.groups.findIndex( + (obj) => obj._id === group_id + ); + if (group_index >= 0) return this.props.groups[group_index].name; + else return 'No group'; + } else { + return 'No group'; + } }; /** @@ -162,30 +220,23 @@ class ExpensesTable extends Component { * @param {Event} e */ onClickEdit = (e) => { - this.resetDeleteExpense(); - const expense = e.currentTarget.closest('tr'); - let origVals = {}; - expense.querySelectorAll('td:not(.expense-action)').forEach((element) => { - origVals[element.getAttribute('name')] = element.getAttribute('orig'); - }); + this.onCancel(); this.setState({ - editExpense: { - id: expense.getAttribute('id'), - ...origVals, - }, + editExpense: this.getExpenseAttributes(e), }); }; onClickDelete = (e) => { - this.resetEditExpense(); + this.onCancel(); this.setState({ - deleteExpenseId: e.currentTarget.closest('tr').getAttribute('id'), + deleteExpense: this.getExpenseAttributes(e), }); }; - onClickCancel = () => { + onCancel = () => { this.resetEditExpense(); this.resetDeleteExpense(); + this.resetSelectExpense(); }; /** @@ -203,162 +254,237 @@ class ExpensesTable extends Component { onEditSubmit = (e) => { e.preventDefault(); + let expense = this.state.editExpense; + if (!expense.group_id) delete expense['group_id']; const expenseDetails = { - ...this.state.editExpense, - date: moment(this.state.editExpense.date).format('YYYY/MM/DD'), - category: this.state.editExpense.category - ? this.state.editExpense.category - : 'Other', + ...expense, + date: moment(expense.date).format('YYYY/MM/DD'), + category: expense.category || 'Other', }; this.props.editExpense(expenseDetails).then(this.resetEditExpense); }; onDeleteSubmit = () => { this.props - .deleteExpense(this.state.deleteExpenseId) + .deleteExpense(this.state.deleteExpense._id) .then(this.resetEditExpense); }; + onClickRow = (e) => { + const expense = this.getExpenseAttributes(e); + const newSelected = + expense._id === this.state.selectExpense._id ? emptyExpense : expense; + this.setState({ + selectExpense: newSelected, + }); + }; + /** - * Renders the passed expense as plain, formatted text - * @param {Object} expense - * @param {Int} i + * Maps fetched expenses to table rows */ - renderRowAsOutput = (expense, i) => { + renderTableBody = () => { + if (this.props.expenses) + return this.props.expenses.map((expense, i) => ( + + + {this.getGroupName(expense.group_id)} + + + {expense.description ? ( + expense.description + ) : ( + No description + )} + + + } + > + + + {expense.title} + + + + {new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + }).format(expense.amount)} + + + + {expense.category} + + + {moment(expense.date).format('MM/DD/YYYY')} + + +
+ + +
+ + +
+ )); + }; + + renderDeleteModal = () => { return ( - - - {expense.title} - - - - {new Intl.NumberFormat('en-US', { - style: 'currency', - currency: 'USD', - }).format(expense.amount)} - - - - {moment(expense.date).format('MM/DD/YYYY')} - - - {expense.category} - - - {expense._id !== this.state.deleteExpenseId ? ( -
- - -
- ) : ( -
- - -
- )} - - + + Delete "{this.state.deleteExpense.title}" + + + Are you sure you want to the expense, " + {this.state.deleteExpense.title} + "? This cannot be undone! + + + + + + ); }; - /** - * Renders the passed expense as a form to edit values - * @param {Object} expense - * @param {Int} i - */ - renderRowAsInput = (expense, i) => { + renderEditModal = () => { return ( - - -
- + + + Edit expense + + + + + + Title + + + + Amount + + + + + + Category + + + + Date + + + + + + Group + 0} + > + {this.props.groups && this.props.groups.length > 0 ? ( + this.props.groups.map((group, i) => ( + + )) + ) : ( + + )} + + + + + + Description + + + - - -
- - - - - - - - - - -
- - -
- - +
+ + + + +
); }; - /** - * Maps fetched expenses to table rows - */ - renderTableBody = () => { - if (this.props.expenses) - return this.props.expenses.map((expense, i) => - expense._id === this.state.editExpense.id - ? this.renderRowAsInput(expense, i) - : this.renderRowAsOutput(expense, i) - ); - }; - renderSortArrow = (headerName) => { return ( -

Expenses

+

{this.props.title ? this.props.title : 'Expenses'}

@@ -397,6 +525,9 @@ class ExpensesTable extends Component { id='table' className='d-flex flex-fill my-3' style={{ height: '0px', overflowY: 'scroll' }} + onScroll={ + this.state.selectExpense._id ? this.resetSelectExpense : null + } > @@ -413,18 +544,18 @@ class ExpensesTable extends Component { {this.renderSortArrow('amount')} - + @@ -438,6 +569,8 @@ class ExpensesTable extends Component { onChange={this.onPageChange} /> + {this.renderDeleteModal()} + {this.renderEditModal()} ); } @@ -451,6 +584,7 @@ const mapStateToProps = (state) => { totalPages: state.expenses.totalPages, update: state.expenses.update, updateAction: state.expenses.updateAction, + groups: state.groups.groups, }; }; diff --git a/frontend/src/components/groupManager.css b/frontend/src/components/groupManager.css new file mode 100644 index 0000000..d7e9cd2 --- /dev/null +++ b/frontend/src/components/groupManager.css @@ -0,0 +1,7 @@ +.group-manager .list-group-item { + cursor: pointer; +} + +.group-manager .list-group-item small { + line-height: 2; +} \ No newline at end of file diff --git a/frontend/src/components/groupManager.jsx b/frontend/src/components/groupManager.jsx new file mode 100644 index 0000000..148dfa7 --- /dev/null +++ b/frontend/src/components/groupManager.jsx @@ -0,0 +1,283 @@ +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import update from 'immutability-helper'; +import { + getGroups, + addGroup, + editGroup, + deleteGroup, + setDefaultGroup, +} from '../store/actions/groupsActions'; + +import './groupManager.css'; +import { Button, ListGroup, Form, Modal } from 'react-bootstrap'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faPen, faTimes } from '@fortawesome/free-solid-svg-icons'; + +class GroupManager extends Component { + constructor(props) { + super(props); + this.state = { + prev_selected_id: '-1', + selected_id: null, + selected_name: 'All expenses', + edit_mode: false, + edit_groups: null, + new_group_name: '', + show_delete_modal: false, + delete_id: null, + delete_name: '', + }; + } + + componentDidUpdate() { + if ( + this.state.prev_selected_id !== this.state.selected_id && + this.props.onChange + ) { + this.setState({ prev_selected_id: this.state.selected_id }); + this.props.onChange({ + id: this.state.selected_id, + name: this.state.selected_name, + }); + } + } + + onClickGroup = (e) => { + const group = e.currentTarget.closest('.list-group-item'); + this.setState({ + selected_id: group.getAttribute('id'), + selected_name: group.getAttribute('name'), + }); + }; + + onClickEdit = () => { + let newState = { edit_mode: !this.state.edit_mode }; + if (!this.state.edit_mode) newState.edit_groups = this.props.groups; + this.setState(newState, () => { + if (!this.state.edit_mode) { + // find differences in state.edit_groups and props.groups and make api requests accordingly + this.props.groups.forEach((group, i) => { + const edit_group = this.state.edit_groups[i]; + if (group.name !== edit_group.name) { + // Update group + this.props.editGroup(edit_group).then(this.props.getGroups); + } + if (edit_group.default && group.default !== edit_group.default) { + // Update user's default_group_id + this.props + .setDefaultGroup(edit_group._id) + .then(this.props.getGroups); + } + }); + } + }); + }; + + onEditChange = (e) => { + const group_id = e.target.closest('.list-group-item').getAttribute('id'); + const group_index = this.state.edit_groups.findIndex( + (obj) => obj._id === group_id + ); + if (e.target.type === 'text') { + this.setState({ + edit_groups: update(this.state.edit_groups, { + [group_index]: { name: { $set: e.target.value } }, + }), + }); + } else if (e.target.type === 'radio') { + const old_default_index = this.state.edit_groups.findIndex( + (obj) => obj.default === true + ); + this.setState( + { + // Unset default + edit_groups: update(this.state.edit_groups, { + [old_default_index]: { default: { $set: false } }, + }), + }, + () => { + // Set default + this.setState({ + edit_groups: update(this.state.edit_groups, { + [group_index]: { default: { $set: true } }, + }), + }); + } + ); + } + }; + + onAddChange = (e) => { + this.setState({ new_group_name: e.target.value }); + }; + + onClickAdd = () => { + this.props + .addGroup(this.state.new_group_name) + .then(this.props.getGroups) + .then(() => { + this.setState({ edit_groups: this.props.groups, new_group_name: '' }); + }); + }; + + onClickDelete = (e) => { + this.setState({ + show_delete_modal: true, + delete_id: e.target.closest('.list-group-item').getAttribute('id'), + delete_name: e.target.closest('.list-group-item').getAttribute('name'), + }); + }; + + onHideModal = () => { + this.setState({ show_delete_modal: false, delete_id: '', delete_name: '' }); + }; + + onDelete = () => { + this.props + .deleteGroup(this.state.delete_id) + .then(this.props.getGroups) + .then(() => { + this.setState({ + edit_groups: this.props.groups, + show_delete_modal: false, + delete_id: '', + delete_name: '', + }); + }); + }; + + renderList = () => { + let groups = [{ _id: null, name: 'All expenses' }]; + if (this.state.edit_mode && this.state.edit_groups) { + groups = [...groups, ...this.state.edit_groups]; + } else if (this.props.groups) { + groups = [...groups, ...this.props.groups]; + } + + if (this.state.edit_mode) + return ( + + {groups.map((group, i) => ( + +
+ {!group._id ? ( + {group.name} + ) : ( + <> + + + + + )} +
+
+ ))} + +
+ + +
+
+
+ ); + else + return ( + + {groups.map((group, i) => ( + +
+ {group.name} + {group.default ? ( + Default + ) : null} +
+
+ ))} +
+ ); + }; + + render() { + return ( + <> +
+
+

Groups

+ +
+ {/* TODO : manage overflow for list */} +
{this.renderList()}
+
+ + + + Delete "{this.state.delete_name}" + + + Are you sure you want to delete the group, "{this.state.delete_name} + "? This cannot be undone! + + + + + + + + ); + } +} + +const mapStateToProps = (state) => { + return { + groups: state.groups.groups, + }; +}; + +const mapDispatchToProps = (dispatch) => { + return { + getGroups: () => dispatch(getGroups()), + addGroup: (group_name) => dispatch(addGroup(group_name)), + editGroup: (group) => dispatch(editGroup(group)), + deleteGroup: (group_id) => dispatch(deleteGroup(group_id)), + setDefaultGroup: (group_id) => dispatch(setDefaultGroup(group_id)), + }; +}; + +export default connect(mapStateToProps, mapDispatchToProps)(GroupManager); diff --git a/frontend/src/components/navBar.jsx b/frontend/src/components/navBar.jsx index 3f5cef0..cabcbb9 100644 --- a/frontend/src/components/navBar.jsx +++ b/frontend/src/components/navBar.jsx @@ -23,10 +23,9 @@ class NavBar extends Component { renderLinks() { if (this.props.isAuthenticated) { return ( - null - // - // Users - // + + Expenses + ); } else { return ( diff --git a/frontend/src/components/newExpenseForm.jsx b/frontend/src/components/newExpenseForm.jsx index 4bfbdd0..20e005a 100644 --- a/frontend/src/components/newExpenseForm.jsx +++ b/frontend/src/components/newExpenseForm.jsx @@ -12,17 +12,29 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faChevronDown } from '@fortawesome/free-solid-svg-icons'; class NewExpenseForm extends Component { - constructor(props) { - super(props); - this.state = { + initialState = () => { + return { title: '', amount: '', category: '', date: moment().format('YYYY-MM-DD'), suggestions: this.props.categories, + group_id: + this.props.groups && this.props.groups.length > 0 + ? this.props.groups[ + this.props.groups.findIndex((obj) => obj.default === true) + ]._id + : null, + description: '', + expanded: false, }; + }; + + constructor(props) { + super(props); + this.state = this.initialState(); } - + onInputChange = (e) => { const field = e.target.name; const value = e.target.value; @@ -31,22 +43,18 @@ class NewExpenseForm extends Component { onSubmit = (e) => { e.preventDefault(); - - this.props - .addNewExpense({ - title: this.state.title, - amount: this.state.amount, - category: this.state.category ? this.state.category : 'Other', - date: moment(this.state.date).format('YYYY/MM/DD'), - }) - .then(() => { - this.setState({ - title: '', - amount: '', - category: '', - date: moment().format('YYYY-MM-DD'), - }); - }); + let expense = { + title: this.state.title, + amount: this.state.amount, + category: this.state.category ? this.state.category : 'Other', + date: moment(this.state.date).format('YYYY/MM/DD'), + description: this.state.description, + }; + if (this.props.groups && this.props.groups.length > 0) + expense.group_id = this.state.group_id; + this.props.addNewExpense(expense).then(() => { + this.setState({ ...this.initialState() }); + }); }; getSuggestions = (value) => { @@ -75,8 +83,8 @@ class NewExpenseForm extends Component { render() { return ( - - + +

Add new expense

@@ -121,8 +129,11 @@ class NewExpenseForm extends Component { + this.setState({ expanded: !this.state.expanded }) + } > More options @@ -130,18 +141,42 @@ class NewExpenseForm extends Component { - +
-

This area does nothing

- Account - - - + Group + 0 + } + > + {this.props.groups && this.props.groups.length > 0 ? ( + this.props.groups.map((group, i) => ( + + )) + ) : ( + + )} + + Description + + +

This area does nothing

{ return { categories: state.expenses.categories, + groups: state.groups.groups, }; }; diff --git a/frontend/src/components/totals.css b/frontend/src/components/totals.css index 5e1236f..faad9e6 100644 --- a/frontend/src/components/totals.css +++ b/frontend/src/components/totals.css @@ -3,10 +3,6 @@ padding-top: 0.5rem; } -h1 { - margin-bottom: 0; -} - .monthly-total { padding-right: 1rem; border-right-style: solid; diff --git a/frontend/src/components/totals.jsx b/frontend/src/components/totals.jsx index b6fa760..085535f 100644 --- a/frontend/src/components/totals.jsx +++ b/frontend/src/components/totals.jsx @@ -18,7 +18,7 @@ class Totals extends Component { style={{ padding: '12px' }} >
-

+

Monthly Total

-

+

+

- +
@@ -94,6 +96,7 @@ const mapStateToProps = (state) => { categories: state.expenses.categories, monthlyTotal: state.expenses.monthlyTotal, yearlyTotal: state.expenses.yearlyTotal, + groups: state.groups.groups, }; }; @@ -104,6 +107,7 @@ const mapDispatchToProps = (dispatch) => { getMonthlyTotal: () => dispatch(getMonthlyTotal()), getYearlyTotal: () => dispatch(getYearlyTotal()), getCategoryAmounts: (options) => dispatch(getCategoryAmounts(options)), + getGroups: () => dispatch(getGroups()), }; }; diff --git a/frontend/src/containers/expenses/expenses.css b/frontend/src/containers/expenses/expenses.css new file mode 100644 index 0000000..c4eb2bf --- /dev/null +++ b/frontend/src/containers/expenses/expenses.css @@ -0,0 +1,9 @@ +.manage { + margin: 0; + height: 100%; +} + +.sidebar { + border-right: 1px solid rgba(0, 0, 0, 0.125); + padding: 0; +} diff --git a/frontend/src/containers/expenses/expenses.jsx b/frontend/src/containers/expenses/expenses.jsx new file mode 100644 index 0000000..6455f45 --- /dev/null +++ b/frontend/src/containers/expenses/expenses.jsx @@ -0,0 +1,74 @@ +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { Redirect } from 'react-router-dom'; +import { + getUserExpenses, + refreshExpenses, +} from '../../store/actions/expensesActions'; +import { getGroups } from '../../store/actions/groupsActions'; +import GroupManager from '../../components/groupManager'; +import ExpensesTable from '../../components/expensesTable'; + +import './expenses.css'; +import { Row, Col } from 'react-bootstrap'; + +class Expenses extends Component { + constructor(props) { + super(props); + + this.state = { + group_id: null, + group_name: 'All expenses', + }; + + this.props.getGroups(); + } + + onGroupChange = (value) => { + this.setState( + { group_id: value.id, group_name: value.name }, + this.props.refreshExpenses + ); + }; + + render() { + if (!this.props.isAuthenticated) { + return ; + } + return ( + <> + + + + + + + + + + ); + } +} + +const mapStateToProps = (state) => { + return { + isAuthenticated: state.users.isAuthenticated, + groups: state.groups.groups, + }; +}; + +const mapDispatchToProps = (dispatch) => { + return { + getGroups: () => dispatch(getGroups()), + getUserExpenses: (options) => dispatch(getUserExpenses(options)), + refreshExpenses: () => dispatch(refreshExpenses()), + }; +}; + +export default connect(mapStateToProps, mapDispatchToProps)(Expenses); diff --git a/frontend/src/index.js b/frontend/src/index.js index 11f3a47..dc9a1ac 100644 --- a/frontend/src/index.js +++ b/frontend/src/index.js @@ -7,12 +7,14 @@ import { createStore, applyMiddleware, combineReducers } from 'redux'; import usersReducer from './store/reducers/usersReducer'; import expensesReducer from './store/reducers/expensesReducer'; +import groupsReducer from './store/reducers/groupsReducer'; import App from './App'; const rootReducer = combineReducers({ users: usersReducer, expenses: expensesReducer, + groups: groupsReducer, }); const store = createStore(rootReducer, applyMiddleware(thunk)); diff --git a/frontend/src/store/actions/actionTypes.js b/frontend/src/store/actions/actionTypes.js index 6db2ec9..19e38af 100644 --- a/frontend/src/store/actions/actionTypes.js +++ b/frontend/src/store/actions/actionTypes.js @@ -8,6 +8,7 @@ export const LOGIN_UNSUCCESSFUL = 'LOGIN_UNSUCCESSFUL'; export const LOGOUT_USER = 'LOGOUT_USER'; // Expenses +export const REFRESH_EXPENSES = 'REFRESH_EXPENSES' export const GET_USER_EXPENSES = 'GET_USER_EXPENSES'; export const GET_USER_CATEGORIES = 'GET_USER_CATEGORIES'; export const ADD_NEW_EXPENSE = 'ADD_NEW_EXPENSE'; @@ -16,3 +17,10 @@ export const DELETE_EXPENSE = 'DELETE_EXPENSE'; export const GET_MONTHLY_TOTAL = 'GET_MONTHLY_TOTAL'; export const GET_YEARLY_TOTAL = 'GET_YEARLY_TOTAL'; export const GET_CATEGORY_AMOUNTS = 'GET_CATEGORY_AMOUNTS'; + +// Groups +export const GET_GROUPS = 'GET_GROUPS'; +export const ADD_GROUP = 'ADD_GROUP'; +export const EDIT_GROUP = 'EDIT_GROUP'; +export const DELETE_GROUP = 'DELETE_GROUP'; +export const SET_DEFAULT_GROUP = 'SET_DEFAULT_GROUP'; diff --git a/frontend/src/store/actions/expensesActions.js b/frontend/src/store/actions/expensesActions.js index 32b1912..2b21b51 100644 --- a/frontend/src/store/actions/expensesActions.js +++ b/frontend/src/store/actions/expensesActions.js @@ -4,6 +4,14 @@ import axiosAPI from '../../helpers/axiosAPI'; import querystring from 'querystring'; import moment from 'moment'; +export const refreshExpenses = () => { + return (dispatch) => { + dispatch({ + type: actionTypes.REFRESH_EXPENSES, + }); + }; +}; + /** * Get the expenses for the current user that follow the specified options * like sort order, sort criteria, start date, end date, etc. @@ -84,7 +92,7 @@ export const editExpense = (expense) => { if (!token) return; return axiosAPI - .put('/expenses/' + expense.id, expense) + .put('/expenses/' + expense._id, expense) .then((res) => { dispatch({ type: actionTypes.EDIT_EXPENSE, diff --git a/frontend/src/store/actions/groupsActions.js b/frontend/src/store/actions/groupsActions.js new file mode 100644 index 0000000..9ad6e09 --- /dev/null +++ b/frontend/src/store/actions/groupsActions.js @@ -0,0 +1,111 @@ +import * as actionTypes from './actionTypes'; +import jwt from 'jsonwebtoken'; +import axiosAPI from '../../helpers/axiosAPI'; + +export const getGroups = () => { + return (dispatch) => { + const token = localStorage.getItem('accessToken'); + if (!token) return; + const username = jwt.decode(token).user.username; + + return axiosAPI + .get('/users/' + username + '/groups') + .then((res) => { + dispatch({ + type: actionTypes.GET_GROUPS, + groups: res.data.groups, + }); + }) + .catch((err) => { + // TODO + }); + }; +}; + +export const addGroup = (group_name) => { + return (dispatch) => { + const token = localStorage.getItem('accessToken'); + if (!token) return; + const username = jwt.decode(token).user.username; + + return axiosAPI + .get('/users/' + username) + .then((res) => { + return axiosAPI + .post('/groups/', { user_id: res.data._id, name: group_name }) + .then((res) => { + dispatch({ + type: actionTypes.ADD_GROUP, + }); + }) + .catch((err) => { + // TODO + }); + }) + .catch((err) => { + // TODO + }); + }; +}; + +export const editGroup = (group) => { + return (dispatch) => { + const token = localStorage.getItem('accessToken'); + if (!token) return; + + return axiosAPI + .put('/groups/' + group._id, { name: group.name }) + .then((res) => { + dispatch({ + type: actionTypes.EDIT_GROUP, + }); + }) + .catch((err) => { + // TODO + }); + }; +}; + +export const deleteGroup = (group_id) => { + return (dispatch) => { + const token = localStorage.getItem('accessToken'); + if (!token) return; + + return axiosAPI + .delete('/groups/' + group_id) + .then((res) => { + dispatch({ + type: actionTypes.DELETE_GROUP, + }); + }) + .catch((err) => { + // TODO + }); + }; +}; + +export const setDefaultGroup = (group_id) => { + return (dispatch) => { + const token = localStorage.getItem('accessToken'); + if (!token) return; + const username = jwt.decode(token).user.username; + + return axiosAPI + .get('/users/' + username) + .then((res) => { + return axiosAPI + .put('/users/' + res.data._id, { default_group_id: group_id }) + .then((res) => { + dispatch({ + type: actionTypes.SET_DEFAULT_GROUP, + }); + }) + .catch((err) => { + // TODO + }); + }) + .catch((err) => { + // TODO + }); + }; +}; diff --git a/frontend/src/store/actions/usersActions.js b/frontend/src/store/actions/usersActions.js index 6a74bb5..6cb79d7 100644 --- a/frontend/src/store/actions/usersActions.js +++ b/frontend/src/store/actions/usersActions.js @@ -31,8 +31,6 @@ export const userRegisterRequest = (userInfo) => { }; export const userLoginRequest = (userLogin) => { - console.log('login requested'); - return (dispatch) => { axiosAuth .post('/login', userLogin) @@ -60,7 +58,6 @@ export const userLoginRequest = (userLogin) => { }; export const userLogoutRequest = () => { - console.log('logout requested'); return (dispatch) => { axiosAuth .delete('/logout', { diff --git a/frontend/src/store/reducers/expensesReducer.js b/frontend/src/store/reducers/expensesReducer.js index 080945f..312b088 100644 --- a/frontend/src/store/reducers/expensesReducer.js +++ b/frontend/src/store/reducers/expensesReducer.js @@ -22,6 +22,13 @@ const reducer = (state = initialState, action) => { totalPages: action.totalPages, }; } + case actionTypes.REFRESH_EXPENSES: { + return { + ...state, + update: state.update + 1, + updateAction: 'refresh', + }; + } case actionTypes.ADD_NEW_EXPENSE: { return { ...state, diff --git a/frontend/src/store/reducers/groupsReducer.js b/frontend/src/store/reducers/groupsReducer.js new file mode 100644 index 0000000..12fe5f8 --- /dev/null +++ b/frontend/src/store/reducers/groupsReducer.js @@ -0,0 +1,35 @@ +import * as actionTypes from '../actions/actionTypes'; + +const initialState = { + groups: null, +}; + +const reducer = (state = initialState, action) => { + switch (action.type) { + case actionTypes.GET_GROUPS: { + return { + ...state, + groups: action.groups, + }; + } + case actionTypes.EDIT_GROUP: { + return state; + } + case actionTypes.SET_DEFAULT_GROUP: { + return state; + } + case actionTypes.ADD_GROUP: { + return state; + } + case actionTypes.DELETE_GROUP: { + return state; + } + case actionTypes.LOGOUT_USER: { + return initialState; + } + default: + return state; + } +}; + +export default reducer; diff --git a/models/expenseModel.js b/models/expenseModel.js index d385969..dba8455 100644 --- a/models/expenseModel.js +++ b/models/expenseModel.js @@ -21,6 +21,13 @@ const expenseSchema = new Schema( }, category: { type: String, + required: true, + }, + group_id: { + type: mongoose.Types.ObjectId, + }, + description: { + type: String, }, }, { diff --git a/models/groupModel.js b/models/groupModel.js new file mode 100644 index 0000000..d9f46ee --- /dev/null +++ b/models/groupModel.js @@ -0,0 +1,24 @@ +const mongoose = require('mongoose'); +const Schema = mongoose.Schema; + +const groupSchema = new Schema( + { + user_id: { + type: mongoose.Types.ObjectId, + required: true, + }, + name: { + type: String, + required: true, + }, + }, + { + timestamps: true, + } +); + +groupSchema.index({ user_id: 1, name: 1 }, { unique: true }); + +const Group = mongoose.model('groups', groupSchema); + +module.exports = Group; diff --git a/models/userModel.js b/models/userModel.js index a6d3fbc..fce5323 100644 --- a/models/userModel.js +++ b/models/userModel.js @@ -1,4 +1,4 @@ -const mongoose = require("mongoose"); +const mongoose = require('mongoose'); const Schema = mongoose.Schema; const userSchema = new Schema( @@ -25,6 +25,9 @@ const userSchema = new Schema( }, }, }, + default_group_id: { + type: mongoose.Types.ObjectId, + }, account_type: { type: String, lowercase: true, @@ -40,6 +43,6 @@ const userSchema = new Schema( } ); -const User = mongoose.model("users", userSchema); +const User = mongoose.model('users', userSchema); module.exports = User; diff --git a/routes/expenses.js b/routes/expenses.js index 0da24c5..0f94c04 100644 --- a/routes/expenses.js +++ b/routes/expenses.js @@ -1,26 +1,23 @@ const express = require('express'); const router = express.Router(); const mongoose = require('mongoose'); +const { auth, authAdmin } = require('./middleware/auth'); const { check, validationResult, query } = require('express-validator'); const User = require('../models/userModel'); const Expense = require('../models/expenseModel'); +const Group = require('../models/groupModel'); const validateGetExpenses = [ check('sort', 'Invalid sort option') .optional() - .isIn(['title', 'date', 'amount', 'category', 'user_id']) - .not() - .isArray(), + .isIn(['title', 'date', 'amount', 'category', 'user_id']), check('direction', 'Invalid direction option') .optional() - .isIn(['asc', 'dsc']) - .not() - .isArray(), - check(['start_date', 'end_date'], 'Invalid date format') - .optional() - .not() - .isArray(), + .isIn(['asc', 'dsc']), + check(['start_date', 'end_date'], 'Invalid date format').optional(), + check('search').optional().isString(), + check('group_id').optional().isMongoId(), check('per_page', 'Invalid number').optional().isInt({ min: 1, max: 100 }), check('page', 'Invalid number').optional().isInt({ min: 1 }), ]; @@ -76,6 +73,11 @@ getExpenses = async (req, res) => { options.skip = (req.query.page - 1) * options.limit; } + // Group + if (req.query.group_id) { + query.group_id = req.query.group_id; + } + // handle username if (req.params.username) { query.user_id = req.user._id; @@ -194,14 +196,20 @@ getExpenseCategories = async (req, res) => { }; // Get all expenses -router.get('/', validateGetExpenses, getExpenses); +router.get('/', authAdmin, validateGetExpenses, getExpenses); // Get all expense categories -router.get('/categories', validateGetExpenseCategories, getExpenseCategories); +router.get( + '/categories', + authAdmin, + validateGetExpenseCategories, + getExpenseCategories +); // Get details of a single expense router.get( '/:expense_id', + auth, [check('expense_id', 'Invalid expense ID').isMongoId()], (req, res) => { let err = validationResult(req); @@ -220,31 +228,36 @@ router.get( // Create new expense router.post( '/', + auth, [ check('user_id', 'User ID must be an ObjectID').isMongoId(), check('title', 'Title is required').notEmpty().isString(), check('amount', 'Amount must be a float').isFloat(), check('date', 'Incorrect date format').isDate(), check('category').notEmpty().isString(), + check('group_id').optional().isMongoId(), + check('description').optional().isString(), ], (req, res) => { let err = validationResult(req); if (!err.isEmpty()) { res.status(400).json(err.errors); } else { - Expense.create( - { - user_id: mongoose.Types.ObjectId(req.body.user_id), - title: req.body.title, - amount: req.body.amount, - date: req.body.date, - category: req.body.category, - }, - (err) => { - if (err) throw err; - res.status(201).json({ message: 'Expense created' }); - } - ); + let query = { + user_id: mongoose.Types.ObjectId(req.body.user_id), + title: req.body.title, + amount: req.body.amount, + date: req.body.date, + category: req.body.category, + }; + if (!req.body.group_id && req.user.default_group_id) + query.group_id = req.user.default_group_id; + else if (req.body.group_id) query.group_id = req.body.group_id; + if (req.body.description) query.description = req.body.description; + Expense.create(query, (err) => { + if (err) throw err; + res.status(201).json({ message: 'Expense created' }); + }); } } ); @@ -259,6 +272,8 @@ router.put( check('amount', 'Amount must be a float').optional().isFloat(), check('date', 'Incorrect date format').optional().isDate(), check('category').optional().notEmpty().isString(), + check('group_id').optional().isMongoId(), + check('description').optional().isString(), ], (req, res) => { let err = validationResult(req); @@ -281,6 +296,7 @@ router.put( // Delete an expense router.delete( '/:expense_id', + auth, [check('expense_id', 'Expense ID must be an ObjectID').isMongoId()], (req, res) => { let err = validationResult(req); diff --git a/routes/groups.js b/routes/groups.js new file mode 100644 index 0000000..190823e --- /dev/null +++ b/routes/groups.js @@ -0,0 +1,148 @@ +const express = require('express'); +const router = express.Router(); +const mongoose = require('mongoose'); +const { auth, authAdmin } = require('./middleware/auth'); +const { check, validationResult, query } = require('express-validator'); + +const Group = require('../models/groupModel'); +const User = require('../models/userModel'); +const Expense = require('../models/expenseModel'); + +getGroups = (req, res) => { + let err = validationResult(req); + if (!err.isEmpty()) return res.status(400).json(err.errors); + + let query = {}; + if (req.params.username) query.user_id = req.user._id; + + Group.find(query, null, { sort: { name: 1 } }, (err, groups) => { + if (err) throw err; + res.status(200).json({ + groups: groups.map((group) => { + return { + ...group.toObject(), + default: String(group._id) === String(req.user.default_group_id), + }; + }), + }); + }); +}; + +/** + * Get all groups + */ +router.get('/', authAdmin, getGroups); + +/** + * Create a new group + */ +router.post( + '/', + auth, + [check('user_id').isMongoId(), check('name').notEmpty().isString()], + (req, res) => { + let err = validationResult(req); + if (!err.isEmpty()) return res.status(400).json(err.errors); + + Group.create( + { + user_id: req.body.user_id, + name: req.body.name, + }, + (err, group) => { + if (err) { + if (err.code === 11000) + res.status(409).json({ message: 'Group name already exists' }); + else throw err; + } + if (!req.user.default_group_id) { + // make the created group the default for the user if they do not have a default + User.findOneAndUpdate( + { _id: group.user_id }, + { default_group_id: group._id }, + (err, user) => { + if (err) throw err; + res.sendStatus(200); + } + ); + } else { + res.sendStatus(200); + } + } + ); + } +); + +/** + * Edit a group + */ +router.put( + '/:group_id', + auth, + [check('group_id').isMongoId(), check('name').isString()], + (req, res) => { + let err = validationResult(req); + if (!err.isEmpty()) return res.status(400).json(err.errors); + + Group.findOneAndUpdate( + { _id: req.params.group_id }, + req.body, + (err, group) => { + if (err) { + if (err.code === 11000) + res.status(409).json({ message: 'Group name already exists' }); + else throw err; + } + if (!group) return res.sendStatus(500); + res.sendStatus(200); + } + ); + } +); + +/** + * Delete an group + */ +router.delete( + '/:group_id', + auth, + [check('group_id').isMongoId()], + (req, res) => { + let err = validationResult(req); + if (!err.isEmpty()) return res.status(400).json(err.errors); + + Group.findOneAndDelete({ _id: req.params.group_id }, (err, group) => { + if (err) throw err; + if (!group) return res.sendStatus(500); + if ( + req.user.default_group_id && + String(req.user.default_group_id) === String(group._id) + ) { + Group.find({ user_id: group.user_id }, (err, groups) => { + if (err) throw err; + let defaultQuery; + if (groups.length > 0) { + // set first result as default group + defaultQuery = { default_group_id: groups[0]._id }; + } else { + // remove default group from user + defaultQuery = { $unset: { default_group_id: 1 } }; + } + + User.findOneAndUpdate( + { _id: group.user_id }, + defaultQuery, + (err, user) => { + if (err) throw err; + res.sendStatus(200); + } + ); + }); + } else { + res.sendStatus(200); + } + }); + } +); + +module.exports = { router: router, getGroups: getGroups }; diff --git a/routes/middleware/auth.js b/routes/middleware/auth.js index bdbdd51..b40225e 100644 --- a/routes/middleware/auth.js +++ b/routes/middleware/auth.js @@ -3,6 +3,7 @@ const config = require('../../config'); const User = require('../../models/userModel'); const Expense = require('../../models/expenseModel'); +const Group = require('../../models/groupModel'); auth = (req, res, next) => { const authHeader = req.headers['authorization']; @@ -15,33 +16,66 @@ auth = (req, res, next) => { if (err) return res.sendStatus(401); User.findOne({ username: decoded.user.username }, (userErr, user) => { - Expense.findOne( - { _id: req.params.expense_id }, - (expenseErr, expense) => { - if (userErr || expenseErr || !user) return res.sendSatus(401); + if (userErr || !user) return res.sendStatus(401); - // add user from payload - req.user = user; + // add user from payload + req.user = user; - // If username is in the URL, confirm token auth - if (req.params.username && req.params.username !== user.username) - return res.sendStatus(401); + let queries = []; + + // TODO : use custom sanitizer with express-validator ? + if (req.params.expense_id) + queries.push(Expense.findOne({ _id: req.params.expense_id }).exec()); + if (req.params.group_id) + queries.push(Group.findOne({ _id: req.params.group_id }).exec()); + if (req.body.expense_id) + queries.push(Expense.findOne({ _id: req.body.expense_id }).exec()); + if (req.body.group_id) + queries.push(Group.findOne({ _id: req.body.group_id }).exec()); + if (req.body.default_group_id) + queries.push( + Group.findOne({ _id: req.body.default_group_id }).exec() + ); - // If user_id is in the body, confirm token auth - if (req.body.user_id && req.body.user_id !== user._id) + Promise.all(queries).then((results) => { + if (results.includes(null)) return res.sendStatus(500); + results.forEach((result) => { + if (String(result.user_id) !== String(user._id)) return res.sendStatus(401); + }); + + // If username is in the URL, confirm token auth + if (req.params.username && req.params.username !== user.username) + return res.sendStatus(401); + + // If user_id is in the body, confirm token auth + if (req.body.user_id && String(req.body.user_id) !== String(user._id)) + return res.sendStatus(401); + + next(); + }); + }); + }); + } catch (e) { + return res.sendStatus(401); + } +}; - // If expense_id is in the URL, confirm token auth (check expense owner) - if (req.params.expense_id) { - if (!expense) return res.sendStatus(401); - if (String(expense.user_id) !== String(user._id)) { - return res.sendStatus(401); - } - } - - next(); - } - ); +authAdmin = (req, res, next) => { + const authHeader = req.headers['authorization']; + const token = authHeader && authHeader.split(' ')[1]; + if (token == null) return res.sendStatus(401); + + try { + // verify token + jwt.verify(token, config.jwt_access_secret, (err, decoded) => { + if (err) return res.sendStatus(401); + + User.findOne({ username: decoded.user.username }, (userErr, user) => { + if (userErr || !user) return res.sendStatus(401); + if (user.account_type !== 'admin' && user.account_type !== 'owner') + return res.sendStatus(401); + next(); }); }); } catch (e) { @@ -49,4 +83,4 @@ auth = (req, res, next) => { } }; -module.exports = auth; +module.exports = { auth: auth, authAdmin: authAdmin }; diff --git a/routes/users.js b/routes/users.js index 033bd50..aa48208 100644 --- a/routes/users.js +++ b/routes/users.js @@ -2,7 +2,7 @@ const express = require('express'); const bcrypt = require('bcrypt'); const router = express.Router(); const jwt = require('jsonwebtoken'); -const auth = require('./middleware/auth'); +const { auth, authAdmin } = require('./middleware/auth'); const { check, validationResult } = require('express-validator'); const { validateGetExpenses, @@ -10,6 +10,7 @@ const { validateGetExpenseCategories, getExpenseCategories, } = require('./expenses'); +const { getGroups } = require('./groups'); const config = require('../config'); @@ -19,6 +20,7 @@ const Expense = require('../models/expenseModel'); // Get list of users router.get( '/', + authAdmin, [ check('type', 'Invalid account type') .optional() @@ -152,8 +154,9 @@ router.post( // Edit user router.put( - '/:id', - [check('id', 'Invalid user ID').isMongoId()], + '/:user_id', + auth, + [check('user_id', 'Invalid user ID').isMongoId()], async (req, res) => { let err = validationResult(req); if (!err.isEmpty()) { @@ -171,9 +174,17 @@ router.put( delete req.body['name']; } + if (req.body.account_type) { + delete req.body['account_type']; + } + + if (req.body.disabled) { + delete req.body['disabled']; + } + User.findOneAndUpdate( { - _id: req.params.id, + _id: req.params.user_id, }, req.body, (err, user) => { @@ -195,8 +206,9 @@ router.put( // Delete user router.delete( - '/:id', - [check('id', 'Invalid user ID').isMongoId()], + '/:user_id', + auth, + [check('user_id', 'Invalid user ID').isMongoId()], (req, res) => { let err = validationResult(req); if (!err.isEmpty()) { @@ -204,7 +216,7 @@ router.delete( } else { User.findOneAndUpdate( { - _id: req.params.id, + _id: req.params.user_id, }, { disabled: true, @@ -237,4 +249,11 @@ router.get( getExpenseCategories ); +router.get( + '/:username/groups', + auth, + [check('username').isAlphanumeric()], + getGroups +); + module.exports = router;
- - Date{' '} - - {this.renderSortArrow('date')} - Category{' '} {this.renderSortArrow('category')} + + Date{' '} + + {this.renderSortArrow('date')} + {/* just for expense action */}