diff --git a/src/common/Header/index.jsx b/src/common/Header/index.jsx index 9023269..963a0fc 100644 --- a/src/common/Header/index.jsx +++ b/src/common/Header/index.jsx @@ -52,7 +52,6 @@ const Header = ({ children, match }) => { const { currentTarget } = event setAnchorEl(currentTarget) setOpen(!open) - console.log('currentTarget', currentTarget) } const handleClose = () => { @@ -98,10 +97,10 @@ const Header = ({ children, match }) => { - + - + diff --git a/src/common/LoginButton/index.jsx b/src/common/LoginButton/index.jsx index 4d7db48..03a5ac9 100644 --- a/src/common/LoginButton/index.jsx +++ b/src/common/LoginButton/index.jsx @@ -23,7 +23,8 @@ import { userLogout } from '../../store/user/actions' const styles = theme => ({ root: { - display: 'flex' + display: 'flex', + minWidth: 135 }, popper: { zIndex: theme.zIndex.drawer + 1 diff --git a/src/common/Logo/index.jsx b/src/common/Logo/index.jsx index 4bc1277..5e6bfb2 100644 --- a/src/common/Logo/index.jsx +++ b/src/common/Logo/index.jsx @@ -8,7 +8,8 @@ import LinkTo from '../LinkTo' const styles = { logo: { - display: 'flex' + display: 'flex', + minWidth: 135 }, image: { width: '30px', diff --git a/src/common/ModalDialog/index.jsx b/src/common/ModalDialog/index.jsx index 0db7605..eb69d26 100644 --- a/src/common/ModalDialog/index.jsx +++ b/src/common/ModalDialog/index.jsx @@ -71,7 +71,7 @@ const ModalDialog = ({ ModalDialog.propTypes = { open: PropTypes.bool.isRequired, title: PropTypes.string.isRequired, - children: PropTypes.array.isRequired, + children: PropTypes.node.isRequired, onSubmit: PropTypes.func.isRequired, onCancel: PropTypes.func.isRequired, classes: PropTypes.object.isRequired, diff --git a/src/core/Accounts/ImportTransactions/CsvImportForm.jsx b/src/core/Accounts/ImportTransactions/CsvImportForm.jsx index 45330bf..5a26bf6 100644 --- a/src/core/Accounts/ImportTransactions/CsvImportForm.jsx +++ b/src/core/Accounts/ImportTransactions/CsvImportForm.jsx @@ -278,7 +278,6 @@ export class CsvImportFormComponent extends React.Component { variant="contained" color="primary" disabled={isSubmitting || file === null || error !== null} - onClick={this.handleButtonClick} > Import diff --git a/src/core/Accounts/ImportTransactions/index.jsx b/src/core/Accounts/ImportTransactions/index.jsx index 733d2de..bd48e34 100644 --- a/src/core/Accounts/ImportTransactions/index.jsx +++ b/src/core/Accounts/ImportTransactions/index.jsx @@ -65,7 +65,7 @@ export class ImportTransactionsComponent extends React.Component { .map(transaction => ({ amount: transaction.amount, description: transaction.description, - category: transaction.category, + categoryId: transaction.categoryId, createdAt: transaction.createdAt })) if (transactions.length > 0) { diff --git a/src/core/Accounts/Transactions/TransactionDialog.jsx b/src/core/Accounts/Transactions/TransactionDialog.jsx index be425b7..af994eb 100644 --- a/src/core/Accounts/Transactions/TransactionDialog.jsx +++ b/src/core/Accounts/Transactions/TransactionDialog.jsx @@ -57,13 +57,6 @@ const colourStyles = { } const mapStateToProps = ({ budget }) => ({ budget }) -// const mapStateToProps = ({ budget }) => ({ -// budget, -// budgetForSelect: budget.tree.map(cat => ({ -// label: budget.byId[cat.id].label, -// options: cat.children.map(subCat => budget.byId[subCat]) -// })) -// }) const mapDispatchToProps = (dispatch) => { return { @@ -112,12 +105,12 @@ export const TransactionDialogComponent = ({ ({ }) const mapStateToProps = (state, props) => ({ - budgetColours: state.budget.colours, + budget: state.budget, formatCurrency: currencyFormatter(state.settings.locale, props.account.currency), formatDecimal: decimalFormatter(state.settings.locale, props.account.type), formatDate: dateFormatter(state.settings.locale) @@ -127,9 +127,10 @@ export class TransactionsTableComponent extends React.Component { } filterByDescription = (transaction) => { - let res = transaction.description.toLowerCase().includes(this.state.filters.description) - if (transaction.category !== undefined) { - res = res || transaction.category.toLowerCase().includes(this.state.filters.description) + let res = transaction.description.toLowerCase().includes(this.state.filters.description.toLowerCase()) + if (transaction.categoryId !== undefined) { + const category = this.props.budget.categoriesById[transaction.categoryId].name + res = res || category.toLowerCase().includes(this.state.filters.description.toLowerCase()) } return res } @@ -198,7 +199,7 @@ export class TransactionsTableComponent extends React.Component { className, children, account, - budgetColours, + budget, formatDate, Toolbar, toolbarProps, @@ -285,17 +286,20 @@ export class TransactionsTableComponent extends React.Component { { - const colour = rowData.category in budgetColours ? budgetColours[rowData.category] : '#dddddd' - return rowData.category !== undefined && ( + if (rowData.categoryId === undefined) return null + const category = budget.categoriesById[rowData.categoryId] + if (category === undefined) return null + if (category.colour === undefined) return null + return ( 5 ? 'black' : 'white' + background: category.colour, + color: chroma.contrast(category.colour, 'black') > 5 ? 'black' : 'white' }} /> ) @@ -341,7 +345,7 @@ TransactionsTableComponent.propTypes = { toolbarProps: PropTypes.object, account: PropTypes.object.isRequired, transactions: PropTypes.array.isRequired, - budgetColours: PropTypes.object.isRequired, + budget: PropTypes.object.isRequired, formatCurrency: PropTypes.func.isRequired, formatDecimal: PropTypes.func.isRequired, formatDate: PropTypes.func.isRequired, diff --git a/src/core/Accounts/Transactions/TransactionsToolbar.jsx b/src/core/Accounts/Transactions/TransactionsToolbar.jsx index d035538..511b9e6 100644 --- a/src/core/Accounts/Transactions/TransactionsToolbar.jsx +++ b/src/core/Accounts/Transactions/TransactionsToolbar.jsx @@ -9,8 +9,8 @@ import Icon from '@mdi/react' import DeleteIcon from '@material-ui/icons/Delete' import { mdiImport } from '@mdi/js' import InputBase from '@material-ui/core/InputBase' +import InputAdornment from '@material-ui/core/InputAdornment' import SearchIcon from '@material-ui/icons/Search' -import CancelIcon from '@material-ui/icons/Cancel' import { fade } from '@material-ui/core/styles/colorManipulator' import confirm from '../../../util/confirm' import TableToolbar from '../../../common/TableToolbar' @@ -34,36 +34,19 @@ const styles = theme => ({ backgroundColor: fade(theme.palette.grey[400], 0.25) } }, - searchIcon: { - width: theme.spacing(9), - height: '100%', - position: 'absolute', - pointerEvents: 'none', - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - color: '#bbb' - }, - clearIcon: { - position: 'absolute', - right: theme.spacing(2), - padding: 0, - marginTop: theme.spacing(2), - color: '#999' - }, inputRoot: { color: 'inherit', - width: '100%' + width: '100%', + padding: theme.spacing(2), + paddingTop: theme.spacing(1), + paddingBottom: theme.spacing(1) + }, inputInput: { - paddingTop: theme.spacing(1) * 1.8, - paddingRight: theme.spacing(5), - paddingBottom: theme.spacing(1), - paddingLeft: theme.spacing(8), transition: theme.transitions.create('width'), width: '100%', [theme.breakpoints.up('md')]: { - minWidth: 230 + minWidth: 240 } } }) @@ -88,10 +71,6 @@ export class TransactionsToolbarComponent extends React.Component { } } - onClearClick = () => { - this.props.filterProps.unsetFilter({ attr: 'description' }) - } - render() { const { classes, @@ -116,29 +95,17 @@ export class TransactionsToolbarComponent extends React.Component { ) : ( - - - } /> - {('description' in this.props.filterProps.filters) && ( - - - - )} { className="" account={account} transactions={[]} - budgetColours={{}} + budget={{}} formatCurrency={mochFormatCurrency} formatDecimal={mochFormatDecimal} formatDate={mochFormatDate} @@ -55,7 +55,7 @@ describe('TransactionsTable', () => { className="" account={account} transactions={transactions} - budgetColours={{}} + budget={{}} formatCurrency={mochFormatCurrency} formatDecimal={mochFormatDecimal} formatDate={mochFormatDate} @@ -78,7 +78,7 @@ describe('TransactionsTable', () => { className="" account={account} transactions={transactions} - budgetColours={{}} + budget={{}} formatCurrency={mochFormatCurrency} formatDecimal={mochFormatDecimal} formatDate={mochFormatDate} diff --git a/src/core/Accounts/Transactions/__tests__/__snapshots__/TransactionsTable.test.jsx.snap b/src/core/Accounts/Transactions/__tests__/__snapshots__/TransactionsTable.test.jsx.snap index 5c23fad..9f893a9 100644 --- a/src/core/Accounts/Transactions/__tests__/__snapshots__/TransactionsTable.test.jsx.snap +++ b/src/core/Accounts/Transactions/__tests__/__snapshots__/TransactionsTable.test.jsx.snap @@ -5,10 +5,10 @@ exports[`TransactionsTable matches snapshot with no transactions 1`] = ` className=" " > - - - - - + + + + + @@ -198,7 +198,7 @@ exports[`TransactionsTable matches snapshot with no transactions 1`] = ` > - - - - - + + + + + @@ -589,7 +589,7 @@ exports[`TransactionsTable matches snapshot with some transactions 1`] = ` > - - - - - + + + + + diff --git a/src/core/Accounts/Transactions/__tests__/__snapshots__/index.test.jsx.snap b/src/core/Accounts/Transactions/__tests__/__snapshots__/index.test.jsx.snap index 2bfb402..a86d366 100644 --- a/src/core/Accounts/Transactions/__tests__/__snapshots__/index.test.jsx.snap +++ b/src/core/Accounts/Transactions/__tests__/__snapshots__/index.test.jsx.snap @@ -19,10 +19,10 @@ exports[`Transactions matches snapshot with no transactions 1`] = ` className="TransactionsTableComponent-tableWrapper-1 " > - - - - - + + + + + @@ -214,7 +214,7 @@ exports[`Transactions matches snapshot with no transactions 1`] = ` > - - - - - + + + + + @@ -638,7 +638,7 @@ exports[`Transactions matches snapshot with one transaction in a fiat account 1` > - - - - You don't have any accounts yet - - - + + + + + + + You don't have any accounts yet. + + + + + + + + + + + + Add an account + + + + + + + + + + + + + + + + + + + + + You can keep track of all the accounts you have from any institution. + + + + + + @@ -91,9 +130,9 @@ exports[`AccountsIndex matches snapshot with one account 1`] = ` - - - + + + @@ -113,9 +152,9 @@ exports[`AccountsIndex matches snapshot with one account 1`] = ` - - - + + + @@ -161,13 +200,13 @@ exports[`AccountsIndex matches snapshot with one account 1`] = ` - - - - - - - + + + + + + + @@ -210,9 +249,9 @@ exports[`AccountsIndex matches snapshot with one account 1`] = ` - - - + + + @@ -327,9 +366,9 @@ exports[`AccountsIndex matches snapshot with two accounts in a different currenc - - - + + + @@ -349,9 +388,9 @@ exports[`AccountsIndex matches snapshot with two accounts in a different currenc - - - + + + @@ -397,13 +436,13 @@ exports[`AccountsIndex matches snapshot with two accounts in a different currenc - - - - - - - + + + + + + + @@ -446,9 +485,9 @@ exports[`AccountsIndex matches snapshot with two accounts in a different currenc - - - + + + @@ -471,9 +510,9 @@ exports[`AccountsIndex matches snapshot with two accounts in a different currenc - - - + + + diff --git a/src/core/Accounts/form.jsx b/src/core/Accounts/form.jsx index 7999d74..89dee0c 100644 --- a/src/core/Accounts/form.jsx +++ b/src/core/Accounts/form.jsx @@ -187,14 +187,11 @@ export class AccountFormComponent extends React.Component { helperText={errors.name} /> ({ balancePaper: { @@ -42,11 +44,6 @@ const styles = theme => ({ balanceIcon: { color: 'white' }, - noAccounts: { - background: grey[100], - margin: '0 20px 20px 25px', - padding: theme.spacing(1) - }, accountsTable: { padding: theme.spacing(2) }, @@ -67,6 +64,14 @@ const styles = theme => ({ padding: theme.spacing(2), height: 240, minWidth: 150 + }, + noAccountsPaper: { + padding: theme.spacing(2), + marginTop: theme.spacing(2), + textAlign: 'center' + }, + newAccountButton: { + margin: theme.spacing(3) } }) @@ -98,9 +103,22 @@ export const AccountsIndexComponent = ({ return ( {!userHasAccounts && ( - - You don't have any accounts yet - + + + You don't have any accounts yet. + + + Add an account + + + You can keep track of all the accounts you have from any institution. + + )} {userHasAccounts && ( diff --git a/src/core/Budget/BudgetChart.jsx b/src/core/Budget/BudgetChart.jsx index 750f0ff..fca453b 100644 --- a/src/core/Budget/BudgetChart.jsx +++ b/src/core/Budget/BudgetChart.jsx @@ -17,19 +17,20 @@ const BudgetChart = () => { const { settings, transactions, - categories, - colours, + budget, formatCurrency } = useSelector(state => ({ settings: state.settings, transactions: state.transactions, - categories: [...new Set(Object.values(state.budget.rules).map(rules => rules.category))], - colours: state.budget.colours, + budget: state.budget, formatCurrency: currencyFormatter(state.settings.locale, state.settings.currency) })) - const initialState = categories.reduce( + const usedCategories = [...new Set(Object.values(budget.rules).map(rules => ( + budget.categoriesById[rules.categoryId] + )))] + const initialState = usedCategories.reduce( (res, cat) => ({ ...res, [cat]: 1 }), {} ) @@ -38,14 +39,15 @@ const BudgetChart = () => { const byMonth = {} transactions.list.forEach((transaction) => { const dateKey = startOfMonth(transaction.createdAt).getTime() - if (transaction.category !== undefined) { + if (transaction.categoryId !== undefined) { + const categoryName = budget.categoriesById[transaction.categoryId].name if (byMonth[dateKey] === undefined) { - byMonth[dateKey] = categories.reduce((res, cat) => ({ ...res, [cat]: 0 }), {}) + byMonth[dateKey] = usedCategories.reduce((res, cat) => ({ ...res, [cat.name]: 0 }), {}) } - if (byMonth[dateKey][transaction.category] === undefined) { - byMonth[dateKey][transaction.category] = 0 + if (byMonth[dateKey][categoryName] === undefined) { + byMonth[dateKey][categoryName] = 0 } - byMonth[dateKey][transaction.category] += Math.abs(transaction.amount) + byMonth[dateKey][categoryName] += Math.abs(transaction.amount) } }) const data = Object.keys(byMonth).sort((a, b) => a - b).map(date => ({ @@ -61,17 +63,17 @@ const BudgetChart = () => { const handleMouseEnter = (obj) => { setOpacity( - categories.reduce((res, value) => ({ + usedCategories.reduce((res, category) => ({ ...res, - [value]: value === obj.dataKey ? 1 : 0.3 + [category.name]: category.name === obj.dataKey ? 1 : 0.3 }), {}) ) } const handleMouseLeave = () => { setOpacity( - categories.reduce((res, value) => ({ + usedCategories.reduce((res, category) => ({ ...res, - [value]: 1 + [category.name]: 1 }), {}) ) } @@ -79,13 +81,13 @@ const BudgetChart = () => { return ( - { categories.map(category => ( + { usedCategories.map(category => ( ))} diff --git a/src/core/BudgetCategories/GroupDialog.jsx b/src/core/BudgetCategories/GroupDialog.jsx new file mode 100644 index 0000000..7a086e7 --- /dev/null +++ b/src/core/BudgetCategories/GroupDialog.jsx @@ -0,0 +1,107 @@ +import React from 'react' +import { compose } from 'recompose' +import { connect } from 'react-redux' +import { withStyles } from '@material-ui/core/styles' +import { withFormik } from 'formik' +import * as Yup from 'yup' +import PropTypes from 'prop-types' +import TextField from '@material-ui/core/TextField' +import ModalDialog from '../../common/ModalDialog' +import { createCategory, updateCategory } from '../../store/budget/actions' + +const styles = theme => ({ + input: { + marginBottom: theme.spacing(1), + marginTop: theme.spacing(1), + width: '100%' + } +}) + +const mapDispatchToProps = (dispatch) => { + return { + handleSave: (category) => { + if ('id' in category) { + return dispatch(updateCategory(category)) + } + return dispatch(createCategory(category)) + } + } +} + +export const GroupDialogComponent = ({ + classes, + handleSubmit, + values, + errors, + touched, + handleChange, + onCancel, + open, + category +}) => ( + + + +) + +GroupDialogComponent.propTypes = { + classes: PropTypes.object.isRequired, + handleSubmit: PropTypes.func.isRequired, + values: PropTypes.object.isRequired, + errors: PropTypes.object.isRequired, + touched: PropTypes.object.isRequired, + handleChange: PropTypes.func.isRequired, + open: PropTypes.bool.isRequired, + onCancel: PropTypes.func.isRequired, + category: PropTypes.object +} + +GroupDialogComponent.defaultProps = { + category: null +} + +export default compose( + connect(null, mapDispatchToProps), + withStyles(styles), + withFormik({ + enableReinitialize: true, + mapPropsToValues: ({ category }) => { + if (category === null) { + return { + name: '' + } + } + return category + }, + validationSchema: Yup.object().shape({ + description: Yup.string() + .max(25, 'Too Long!') + }), + handleSubmit: (values, { props, setSubmitting, resetForm }) => { + setSubmitting(true) + props.handleSave(values) + resetForm() + setSubmitting(false) + props.onCancel() + } + }) +)(GroupDialogComponent) diff --git a/src/core/BudgetCategories/__tests__/__snapshots__/index.test.jsx.snap b/src/core/BudgetCategories/__tests__/__snapshots__/index.test.jsx.snap new file mode 100644 index 0000000..581a850 --- /dev/null +++ b/src/core/BudgetCategories/__tests__/__snapshots__/index.test.jsx.snap @@ -0,0 +1,7365 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`BudgetCAtegoriesIndex matches snapshot with no accounts 1`] = ` +" + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Manage categories + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + All groups + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Bills & Utilities + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Home Phone + + + + + + + Budget limit: Not set + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Internet + + + + + + + Budget limit: Not set + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Mobile Phone + + + + + + + Budget limit: Not set + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + New category + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Education + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Books & Supplies + + + + + + + Budget limit: Not set + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Student Loan + + + + + + + Budget limit: Not set + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Tuition + + + + + + + Budget limit: Not set + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + New category + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Food & Dining + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Groceries + + + + + + + Budget limit: Not set + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Restaurants + + + + + + + Budget limit: Not set + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + New category + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Gifts & Donations + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Charity + + + + + + + Budget limit: Not set + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Gift + + + + + + + Budget limit: Not set + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + New category + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Health & Fitness + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Dentist + + + + + + + Budget limit: Not set + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Doctor + + + + + + + Budget limit: Not set + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Eyecare + + + + + + + Budget limit: Not set + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Pharmacy + + + + + + + Budget limit: Not set + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Gym + + + + + + + Budget limit: Not set + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Sports + + + + + + + Budget limit: Not set + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + New category + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Home + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Furniture + + + + + + + Budget limit: Not set + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Home Improvement + + + + + + + Budget limit: Not set + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Home Services + + + + + + + Budget limit: Not set + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Home Supplies + + + + + + + Budget limit: Not set + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Lawn & Garden + + + + + + + Budget limit: Not set + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Mortgage & Rent + + + + + + + Budget limit: Not set + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + New category + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Income + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Bonus + + + + + + + Budget limit: Not set + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Interest Income + + + + + + + Budget limit: Not set + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Paycheque + + + + + + + Budget limit: Not set + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Reimbursement + + + + + + + Budget limit: Not set + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Rental Income + + + + + + + Budget limit: Not set + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Returned Purchase + + + + + + + Budget limit: Not set + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + New category + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Insurance + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Car insurance + + + + + + + Budget limit: Not set + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Life insurance + + + + + + + Budget limit: Not set + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Health Insurance + + + + + + + Budget limit: Not set + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Home insurance + + + + + + + Budget limit: Not set + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + New category + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Investments + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Buy + + + + + + + Budget limit: Not set + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Deposit + + + + + + + Budget limit: Not set + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Dividend & Cap Gains + + + + + + + Budget limit: Not set + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Sell + + + + + + + Budget limit: Not set + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Withdrawal + + + + + + + Budget limit: Not set + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + New category + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Kids + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Kids Allowance + + + + + + + Budget limit: Not set + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Baby Supplies + + + + + + + Budget limit: Not set + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Babysitter & Daycare + + + + + + + Budget limit: Not set + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Child Support + + + + + + + Budget limit: Not set + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Kids Activities + + + + + + + Budget limit: Not set + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Kids Toys + + + + + + + Budget limit: Not set + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + New category + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Pets + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Pet Food & Supplies + + + + + + + Budget limit: Not set + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Pet Grooming + + + + + + + Budget limit: Not set + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Veterinary + + + + + + + Budget limit: Not set + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + New category + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Shopping + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Books + + + + + + + Budget limit: Not set + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Clothing + + + + + + + Budget limit: Not set + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Electronics & Software + + + + + + + Budget limit: Not set + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Hobbies + + + + + + + Budget limit: Not set + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Sporting Goods + + + + + + + Budget limit: Not set + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + New category + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Subscriptions + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Netflix + + + + + + + Budget limit: Not set + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Spotify + + + + + + + Budget limit: Not set + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + New category + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Taxes + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Federal Tax + + + + + + + Budget limit: Not set + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Local Tax + + + + + + + Budget limit: Not set + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Property Tax + + + + + + + Budget limit: Not set + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Sales Tax + + + + + + + Budget limit: Not set + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Provincial Tax + + + + + + + Budget limit: Not set + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + New category + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Transport + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Auto Payment + + + + + + + Budget limit: Not set + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Gas & Fuel + + + + + + + Budget limit: Not set + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Parking + + + + + + + Budget limit: Not set + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Public Transportation + + + + + + + Budget limit: Not set + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Service & Parts + + + + + + + Budget limit: Not set + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + New category + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Travel + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Flights + + + + + + + Budget limit: Not set + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Hotel + + + + + + + Budget limit: Not set + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Rental Car & Taxi + + + + + + + Budget limit: Not set + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Vacation + + + + + + + Budget limit: Not set + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + New category + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Uncategorized + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Cash & ATM + + + + + + + Budget limit: Not set + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Cheque + + + + + + + Budget limit: Not set + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + New category + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +" +`; diff --git a/src/core/BudgetCategories/__tests__/index.test.jsx b/src/core/BudgetCategories/__tests__/index.test.jsx new file mode 100644 index 0000000..3b2de4b --- /dev/null +++ b/src/core/BudgetCategories/__tests__/index.test.jsx @@ -0,0 +1,29 @@ +import React from 'react' +import { mount } from 'enzyme' +import { Provider } from 'react-redux' +import { BrowserRouter } from 'react-router-dom' +import configureMockStore from 'redux-mock-store' +import BudgetCategories from '..' +import { initialState as settingsInitialState } from '../../../store/settings/reducer' +import { initialState as budgetInitialState } from '../../../store/budget/reducer' + +describe('BudgetCAtegoriesIndex', () => { + it('matches snapshot with no accounts', () => { + const mockStore = configureMockStore() + const store = mockStore({ + settings: settingsInitialState, + budget: budgetInitialState + }) + const wrapper = mount(( + + + + + + )) + expect(wrapper.debug()).toMatchSnapshot() + + const groupNames = budgetInitialState.categoryTree.map(cat => cat.label) + expect(wrapper.find('h6').map(node => node.text())).toEqual(groupNames) + }) +}) diff --git a/src/core/BudgetCategories/form.jsx b/src/core/BudgetCategories/form.jsx new file mode 100644 index 0000000..20d9b13 --- /dev/null +++ b/src/core/BudgetCategories/form.jsx @@ -0,0 +1,135 @@ +import React from 'react' +import { compose } from 'recompose' +import { connect } from 'react-redux' +import { withStyles } from '@material-ui/core/styles' +import { withFormik } from 'formik' +import * as Yup from 'yup' +import PropTypes from 'prop-types' +import TextField from '@material-ui/core/TextField' +import Button from '@material-ui/core/Button' +import SubmitButtonWithProgress from '../../common/SubmitButtonWithProgress' +import { createCategory, updateCategory } from '../../store/budget/actions' + +const styles = theme => ({ + form: { + display: 'flex', + flexDirection: 'column', + padding: theme.spacing(2), + paddingBottom: 0 + }, + input: { + // margin: theme.spacing(2) + }, + formActions: { + display: 'flex', + justifyContent: 'flex-end', + paddingTop: 10 + }, + buttonWrapper: { + margin: theme.spacing(1), + position: 'relative' + } +}) + +const mapStateToProps = state => ({ + budget: state.budget +}) + +const mapDispatchToProps = { + handleCreate: (category, groupId) => createCategory(category, groupId), + handleUpdate: category => updateCategory(category) +} + +const CategoryFormComponent = ({ + classes, + handleSubmit, + isSubmitting, + values, + errors, + touched, + handleChange, + handleCancel +}) => { + return ( + + + + + + + Cancel + + + + ) +} + +CategoryFormComponent.propTypes = { + classes: PropTypes.object.isRequired, + handleSubmit: PropTypes.func.isRequired, + isSubmitting: PropTypes.bool.isRequired, + values: PropTypes.object.isRequired, + errors: PropTypes.object.isRequired, + touched: PropTypes.object.isRequired, + handleChange: PropTypes.func.isRequired, + handleCancel: PropTypes.func.isRequired +} + +export default compose( + connect(mapStateToProps, mapDispatchToProps), + withStyles(styles), + withFormik({ + enableReinitialize: true, + mapPropsToValues: ({ category }) => { + if (category === undefined) { + return { + name: '', + budgetLimit: 0 + } + } + return category + }, + validationSchema: Yup.object().shape({ + name: Yup.string() + .max(100, 'Too Long!') + .required('Please enter a name') + }), + handleSubmit: async (values, { props, setSubmitting }) => { + setSubmitting(true) + if ('id' in values) { + props.handleUpdate(values) + } else { + props.handleCreate(values, props.groupId) + } + props.handleCancel() + setSubmitting(false) + } + }) +)(CategoryFormComponent) diff --git a/src/core/BudgetCategories/index.jsx b/src/core/BudgetCategories/index.jsx index 9380cf2..1b06844 100644 --- a/src/core/BudgetCategories/index.jsx +++ b/src/core/BudgetCategories/index.jsx @@ -1,96 +1,361 @@ +/* eslint-disable react/no-multi-comp */ import React from 'react' -import { useSelector } from 'react-redux' +import { useSelector, useDispatch } from 'react-redux' import { makeStyles } from '@material-ui/core/styles' import Grid from '@material-ui/core/Grid' -import Paper from '@material-ui/core/Paper' -import List from '@material-ui/core/List' -import ListItem from '@material-ui/core/ListItem' -import ListItemIcon from '@material-ui/core/ListItemIcon' -import ListItemText from '@material-ui/core/ListItemText' -import ListItemSecondaryAction from '@material-ui/core/ListItemSecondaryAction' -import Collapse from '@material-ui/core/Collapse' +import Container from '@material-ui/core/Container' +import InputBase from '@material-ui/core/InputBase' +import InputAdornment from '@material-ui/core/InputAdornment' import Typography from '@material-ui/core/Typography' -import ExpandLess from '@material-ui/icons/ExpandLess' -import ExpandMore from '@material-ui/icons/ExpandMore' -import FolderIcon from '@material-ui/icons/Folder' +import SearchIcon from '@material-ui/icons/Search' +import AddIcon from '@material-ui/icons/Add' import EditIcon from '@material-ui/icons/Edit' +import DeleteIcon from '@material-ui/icons/Delete' +import MoreVertIcon from '@material-ui/icons/MoreVert' import IconButton from '@material-ui/core/IconButton' +import Button from '@material-ui/core/Button' +import Menu from '@material-ui/core/Menu' +import MenuItem from '@material-ui/core/MenuItem' +import MenuList from '@material-ui/core/MenuList' +import ListItemIcon from '@material-ui/core/ListItemIcon' +import ListItemText from '@material-ui/core/ListItemText' +import Card from '@material-ui/core/Card' +import CardHeader from '@material-ui/core/CardHeader' +import Select from 'react-select' +import { fade } from '@material-ui/core/styles/colorManipulator' +import Tooltip from '@material-ui/core/Tooltip' +import pluralize from 'pluralize' +import CategoryForm from './form' +import { currencyFormatter } from '../../util/stringFormatter' +import { deleteCategory } from '../../store/budget/actions' +import GroupDialog from './GroupDialog' +import confirm from '../../util/confirm' const useStyles = makeStyles(theme => ({ root: { - padding: theme.spacing(3), - flexDirection: 'column' + marginTop: theme.spacing(2), + marginBottom: theme.spacing(6) + }, + categoryGroup: { + marginTop: theme.spacing(2) }, - nested: { - paddingLeft: theme.spacing(3) + groupName: { + display: 'inline-block', + marginRight: theme.spacing(1) }, - dot: { - borderRadius: 4, + circle: { + borderRadius: 20, content: '" "', display: 'block', - marginRight: -10, + marginRight: 10, marginLeft: 10, - height: 15, - width: 15 + height: 30, + width: 30 + }, + newCategoryButton: { + marginTop: theme.spacing(2), + textAlign: 'center' + }, + inputRoot: { + verticalAlign: 'bottom', + padding: theme.spacing(2), + paddingTop: theme.spacing(1) * 0.5, + paddingBottom: theme.spacing(1) * 0.5, + borderRadius: theme.shape.borderRadius * 2, + backgroundColor: fade(theme.palette.grey[400], 0.15), + marginRight: theme.spacing(2), + marginLeft: 0, + '&:hover': { + backgroundColor: fade(theme.palette.grey[400], 0.25) + } + }, + inputInput: { + width: 160, + color: 'inherit', + transition: theme.transitions.create('width') + }, + filterParentCategory: { + width: 230, + marginLeft: theme.spacing(1), + marginRight: theme.spacing(2), + display: 'inline-block' } })) + const BudgetCategories = () => { const classes = useStyles() - const budget = useSelector(state => state.budget) - const [open, setOpen] = React.useState(budget.categories.reduce( - (result, category) => ({ ...result, [category.label]: true }), - {} - )) - - function handleClick(category) { - setOpen({ ...open, [category]: !open[category] }) + const { budget, formatCurrency } = useSelector(state => ({ + budget: state.budget, + formatCurrency: currencyFormatter(state.settings.locale, state.settings.currency) + })) + const dispatch = useDispatch() + + const [filter, setfilter] = React.useState({ + category: '', + parentCategoryId: '' + }) + const [popupCategoryId, setPopupCategoryId] = React.useState(null) + const [anchorEl, setAnchorEl] = React.useState(null) + const [editCategory, setEditCategory] = React.useState(null) + const [openGroupDialog, setOpenGroupDialog] = React.useState(false) + const [selectedGroup, setSelectedGroup] = React.useState(null) + const popupIsOpen = Boolean(anchorEl) + + // --- Filters ---- + function handleFilterChange(event) { + if ('persist' in event) event.persist() + setfilter(oldValues => ({ + ...oldValues, + [event.target.name]: event.target.value + })) + } + + const filteredCategories = () => { + const filteredTree = budget.categoryTree.reduce((res, cat) => { + if (filter.parentCategoryId === '' || cat.id === filter.parentCategoryId) { + return [...res, { ...cat, options: cat.options }] + } + return res + }, []) + if (filter.category !== '') { + filteredTree.forEach((parentCategory, index) => { + filteredTree[index].options = parentCategory.options.filter(category => ( + category.label.toLowerCase().includes(filter.category.toLowerCase()) + )) + }) + } + return filteredTree + } + + // --- Popuop --- + const handleClosePopup = () => { + setAnchorEl(null) + } + + const handleOpenPopup = (event, categoryId) => { + setAnchorEl(popupCategoryId === categoryId && popupIsOpen ? null : event.currentTarget) + setPopupCategoryId(categoryId) + setEditCategory(null) + } + + // --- Category form --- + const showNewCategoryForm = (groupId) => { + setEditCategory(groupId) + setAnchorEl(null) + } + + const showEditCategoryForm = () => { + setEditCategory(popupCategoryId) + setAnchorEl(null) + } + + const handleCloseForm = () => { + setEditCategory(null) + } + + const handleDeleteCategory = () => { + const category = budget.categoriesById[popupCategoryId] + let confirmText = 'Are you sure?' + const transactionsCount = Object.values(budget.rules).reduce((count, rule) => ( + rule.categoryId === category.id ? count + rule.count : count + ), 0) + if (transactionsCount > 0) { + confirmText += ` There will be ${pluralize('transaction', transactionsCount, true)} affected.` + } + confirm(`Delete ${category.name}.`, confirmText).then(async () => { + dispatch(deleteCategory(popupCategoryId)) + }) + handleClosePopup() + } + + // --- Group dialog --- + const handleNewGroup = () => { + setSelectedGroup(null) + setOpenGroupDialog(true) + } + + const handleEditGroup = (category) => { + setSelectedGroup(budget.categoriesById[category.id]) + setOpenGroupDialog(true) + } + + const handleDeleteGroup = (category) => { + let title = `Delete ${category.label} group` + const childCategoryIds = category.options.map(item => item.id) + if (category.options.length > 0) { + title += ` and ${pluralize('categories', category.options.length, true)}.` + } + let confirmText = 'Are you sure?' + const transactionsCount = Object.values(budget.rules).reduce((count, rule) => ( + rule.categoryId === category.id || childCategoryIds.includes(rule.categoryId) ? count + rule.count : count + ), 0) + if (transactionsCount > 0) { + confirmText += ` There will be ${pluralize('transaction', transactionsCount, true)} affected.` + } + confirm(title, confirmText).then(async () => { + dispatch(deleteCategory(category.id)) + }) + } + + const handleCloseGroupDialog = () => { + setSelectedGroup(null) + setOpenGroupDialog(false) + } + + const renderCategory = (categoryId) => { + const category = budget.categoriesById[categoryId] + if (editCategory !== null && categoryId === editCategory) { + return ( + + ) + } + return ( + } + action={( + handleOpenPopup(event, category.id)}> + + + )} + title={category.name} + subheader={`Budget limit: ${category.budgetLimit ? formatCurrency(category.budgetLimit) : 'Not set'}`} + subheaderTypographyProps={{ + variant: 'caption' + }} + /> + ) + } + + const renderNewCategory = (groupId) => { + if (editCategory !== null && groupId === editCategory) { + return ( + + + + ) + } + return ( + showNewCategoryForm(groupId)}> + New category + + + ) } return ( - - - Budget categories - - - {budget.categories.map(topCategory => ( - - handleClick(topCategory.label)}> - - - {open[topCategory.label] ? : } - - - {topCategory.options.map(category => ( - - - - - - - - - - - - - - ))} - - - ))} - - + + + + Manage categories + + } + /> + ({ label: cat.label, value: cat.id }))} + inputProps={{ 'aria-label': 'All categories' }} + onChange={(value) => { + handleFilterChange({ + target: { + name: 'parentCategoryId', + value: value === null ? '' : value.value + } + }) + }} + className={classes.filterParentCategory} + isClearable + /> + + + + + + - + {filteredCategories().map(group => ( + + + {group.label} + + handleEditGroup(group)} style={{ padding: 8 }}> + + + + + handleDeleteGroup(group)} style={{ padding: 8 }}> + + + + + + {group.options.map(category => ( + + {renderCategory(category.id)} + + ))} + + {renderNewCategory(group.id)} + + + ))} + + + + + + + + + + + + + + + + + ) } diff --git a/src/core/Settings/index.jsx b/src/core/Settings/index.jsx index 20ee505..f7400b3 100644 --- a/src/core/Settings/index.jsx +++ b/src/core/Settings/index.jsx @@ -33,7 +33,7 @@ const mapDispatchToProps = dispatch => ({ saveSettings: settings => dispatch(updateSettings(settings)), showSnackbarMessage: message => dispatch(showSnackbar(message)), deleteAllData: async () => { - await dispatch(resetState()) + dispatch(resetState()) await saveState() } }) diff --git a/src/data/budgetCategories.js b/src/data/budgetCategories.js index ecee92a..489e174 100644 --- a/src/data/budgetCategories.js +++ b/src/data/budgetCategories.js @@ -22,7 +22,7 @@ const budgetCategories = { 'Spotify' ], 'Insurance': [ - 'Car nsurance', + 'Car insurance', 'Life insurance', 'Health Insurance', 'Home insurance' diff --git a/src/store/accounts/__test__/actions.test.js b/src/store/accounts/__test__/actions.test.js index be05688..94ce365 100644 --- a/src/store/accounts/__test__/actions.test.js +++ b/src/store/accounts/__test__/actions.test.js @@ -10,20 +10,6 @@ import { convertToLocalCurrency } from '../../exchangeRates/actions' jest.mock('uuid/v4', () => jest.fn(() => 'xyz')) const mockStore = configureMockStore([thunk]) -// Mock call to alphavantage in fetchExchangeRates -// window.fetch = jest.fn().mockImplementation(() => ( -// Promise.resolve(new window.Response( -// JSON.stringify({ -// 'Realtime Currency Exchange Rate': { -// '6. Last Refreshed': '2018-01-01', -// '5. Exchange Rate': 1 -// } -// }), { -// status: 200, -// headers: { 'Content-type': 'application/json' } -// } -// )) -// )) beforeEach(() => { jest.resetModules() diff --git a/src/store/budget/__tests__/actions.test.js b/src/store/budget/__tests__/actions.test.js index 06484c2..bdd4ebd 100644 --- a/src/store/budget/__tests__/actions.test.js +++ b/src/store/budget/__tests__/actions.test.js @@ -4,11 +4,22 @@ import * as actions from '../actions' import types from '../types' import { initialState as budgetInitialState } from '../reducer' + +// jest.mock('uuid/v4', () => jest.fn(() => {'xyz'})) +jest.mock('uuid/v4', () => { + let value = 0 + return () => { + value += 1 + return value + } +}) + beforeEach(() => { jest.resetModules() jest.clearAllMocks() }) +const mockStore = configureMockStore([thunk]) const transaction = { accountId: 1, description: 'Shopping Mart', @@ -19,7 +30,7 @@ const transaction = { const budget = { ...budgetInitialState, rules: { - 'Shopping Mart': budgetInitialState.categories[2].options[0].label + 'Shopping Mart': budgetInitialState.categoryTree[2].options[0].label } } @@ -31,40 +42,147 @@ describe('budget actions', () => { }) }) - it('should create an exact rule', async () => { - const mockStore = configureMockStore([thunk]) - const store = mockStore({}) - const category = 'Groceries' - const match = 'Shopping Mart' - await store.dispatch(actions.createExactRule(category, match)) - expect(store.getActions()).toEqual([{ - type: 'CREATE_EXACT_RULE', - payload: { category, match } - }]) - }) + describe('categories', () => { + it('should create a group', async () => { + const store = mockStore({}) + await store.dispatch(actions.createCategory({ name: 'group 1' })) + expect(store.getActions()).toEqual([{ + type: 'CREATE_CATEGORY', + payload: { name: 'group 1', id: Object.keys(budgetInitialState.categoriesById).length + 1 } + }, + { + type: 'SHOW_SNACKBAR', + payload: { status: 'success', text: 'Group created' } + }]) + }) + + it('should create a category', async () => { + const store = mockStore({ budget }) + await store.dispatch(actions.createCategory({ name: 'cat 1' }, 1)) + expect(store.getActions()).toEqual([{ + type: 'CREATE_CATEGORY', + payload: { + name: 'cat 1', + id: Object.keys(budgetInitialState.categoriesById).length + 2, + parentId: 1 + } + }, + { + type: 'SHOW_SNACKBAR', + payload: { status: 'success', text: 'Category created' } + }]) + }) + + it('should update a group', async () => { + const store = mockStore({ budget }) + await store.dispatch(actions.updateCategory({ id: 0, name: 'group 1' })) + expect(store.getActions()).toEqual([{ + type: 'UPDATE_CATEGORY', + payload: { name: 'group 1', id: 0 } + }, + { + type: 'SHOW_SNACKBAR', + payload: { status: 'success', text: 'Group updated' } + }]) + }) - it('should delete an exact rule', async () => { - const mockStore = configureMockStore([thunk]) - const store = mockStore() - const match = 'Shopping Mart' - await store.dispatch(actions.deleteExactRule(match)) - expect(store.getActions()).toEqual([{ - type: 'DELETE_EXACT_RULE', - payload: match - }]) + it('should update a category', async () => { + const store = mockStore({ budget }) + await store.dispatch(actions.updateCategory({ id: 1, name: 'cat 1', parentId: 0 })) + expect(store.getActions()).toEqual([{ + type: 'UPDATE_CATEGORY', + payload: { name: 'cat 1', id: 1, parentId: 0 } + }, + { + type: 'SHOW_SNACKBAR', + payload: { status: 'success', text: 'Category updated' } + }]) + }) + + it('should delete a group', async () => { + const store = mockStore({ budget }) + const categoryId = 1 + await store.dispatch(actions.deleteCategory(categoryId)) + expect(('parentId' in budget.categoriesById[categoryId])).toBe(false) + const categoryIds = Object.values(budget.categoriesById).reduce((res, cat) => ( + cat.parentId === categoryId ? [...res, cat.id] : res + ), [categoryId]) + + expect(store.getActions()).toEqual([{ + type: 'DELETE_CATEGORY', + payload: categoryId + }, + { + type: 'UPATE_TRANSACTION_FIELD_IF_MATCHED', + payload: { + fieldName: 'categoryId', + newValue: undefined, + values: categoryIds + } + }, + { + type: 'SHOW_SNACKBAR', + payload: { status: 'success', text: 'Group deleted' } + }]) + }) + + it('should delete a category', async () => { + const store = mockStore({ budget }) + const categoryId = 2 + await store.dispatch(actions.deleteCategory(categoryId)) + expect(('parentId' in budget.categoriesById[categoryId])).toBe(true) + expect(store.getActions()).toEqual([{ + type: 'DELETE_CATEGORY', + payload: categoryId + }, + { + type: 'UPATE_TRANSACTION_FIELD_IF_MATCHED', + payload: { + fieldName: 'categoryId', + newValue: undefined, + values: [categoryId] + } + }, + { + type: 'SHOW_SNACKBAR', + payload: { status: 'success', text: 'Category deleted' } + }]) + }) }) - it('should count rules usage', async () => { - const mockStore = configureMockStore([thunk]) - const store = mockStore({ - transactions: { - list: [transaction] - } + describe('rules', () => { + it('should create an exact rule', async () => { + const store = mockStore({}) + const category = budgetInitialState.categoryTree[2] + const match = 'Shopping Mart' + await store.dispatch(actions.createExactRule(category.id, match)) + expect(store.getActions()).toEqual([{ + type: 'CREATE_EXACT_RULE', + payload: { categoryId: category.id, match } + }]) + }) + + it('should delete an exact rule', async () => { + const store = mockStore() + const match = 'Shopping Mart' + await store.dispatch(actions.deleteExactRule(match)) + expect(store.getActions()).toEqual([{ + type: 'DELETE_EXACT_RULE', + payload: match + }]) + }) + + it('should count rules usage', async () => { + const store = mockStore({ + transactions: { + list: [transaction] + } + }) + await store.dispatch(actions.countRuleUsage()) + expect(store.getActions()).toEqual([{ + type: 'COUNT_RULE_USAGE', + payload: [transaction] + }]) }) - await store.dispatch(actions.countRuleUsage()) - expect(store.getActions()).toEqual([{ - type: 'COUNT_RULE_USAGE', - payload: [transaction] - }]) }) }) diff --git a/src/store/budget/__tests__/reducer.test.js b/src/store/budget/__tests__/reducer.test.js index f308cac..f101ab1 100644 --- a/src/store/budget/__tests__/reducer.test.js +++ b/src/store/budget/__tests__/reducer.test.js @@ -1,4 +1,4 @@ -import budgetReducer, { initialState } from '../reducer' +import budgetReducer, { initialState, defaultColours, generateCategoryTree } from '../reducer' import types from '../types' const budget = { @@ -9,31 +9,168 @@ const budget = { } describe('budget reducer', () => { - it('should return initial state', () => { - expect(budgetReducer(undefined, {})).toEqual(initialState) + describe('initialState', () => { + it('should return initial state', () => { + expect(budgetReducer(undefined, {})).toEqual(initialState) + expect(Object.keys(initialState.categoriesById).length).toBe(86) + const groupsCount = Object.values(initialState.categoriesById).reduce( + (res, cat) => ('parentId' in cat ? res : res + 1), + 0 + ) + expect(Object.keys(initialState.categoryTree).length).toBe(groupsCount) + }) + }) + + describe('LOAD_BUDGET', () => { + it('should handle LOAD_BUDGET', () => { + const type = types.LOAD_BUDGET + const payload = budget + expect(budgetReducer(undefined, { type, payload })).toEqual(payload) + }) + + it('should handle LOAD_BUDGET with no existing data', () => { + const type = types.LOAD_BUDGET + const payload = null + expect(budgetReducer(undefined, { type, payload })).toEqual(initialState) + }) + }) + + describe('CREATE_CATEGORY', () => { + it('should create a group', () => { + const type = types.CREATE_CATEGORY + const payload = { id: 1, name: 'cat 1' } + expect(budgetReducer(undefined, { type, payload })).toEqual({ + ...initialState, + categoriesById: { + ...initialState.categoriesById, + [payload.id]: payload + }, + categoryTree: [ + ...initialState.categoryTree, + { id: payload.id, label: payload.name, options: [] } + ].sort((a, b) => ((a.label.toLowerCase() > b.label.toLowerCase()) ? 1 : -1)) + }) + }) + + it('should create a category in group with no categories', () => { + const type = types.CREATE_CATEGORY + const group = { id: 1, name: 'group 1' } + const simpleInitialState = { + categoriesById: { 1: group }, + categoryTree: generateCategoryTree({ 1: group }) + } + const payload = { id: 1, name: 'cat 1', parentId: group.id } + const categoriesById = { + 1: group, + [payload.id]: { ...payload, colour: defaultColours[0] } + } + expect(budgetReducer(simpleInitialState, { type, payload })).toEqual({ + categoriesById, + categoryTree: generateCategoryTree(categoriesById) + }) + }) + + it('should create a category in group with other categories', () => { + const type = types.CREATE_CATEGORY + + const group = initialState.categoryTree[0] + expect(group.label).toBe('Bills & Utilities') + expect(group.options.length).toBeGreaterThan(0) + + const lastColour = group.options.length === 0 + ? defaultColours[defaultColours.length - 1] + : group.options[group.options.length - 1].colour + const lastColourIndex = defaultColours.indexOf(lastColour) + const payload = { id: 1, name: 'cat 1', parentId: group.id } + const categoriesById = { + ...initialState.categoriesById, + [payload.id]: { + ...payload, + colour: defaultColours[(lastColourIndex + 1) % defaultColours.length] + } + } + expect(budgetReducer(undefined, { type, payload })).toEqual({ + ...initialState, + categoriesById, + categoryTree: generateCategoryTree(categoriesById) + }) + }) }) - it('should handle LOAD_BUDGET', () => { - const type = types.LOAD_BUDGET - const payload = budget - expect(budgetReducer(undefined, { type, payload })).toEqual(payload) + describe('UPDATE_CATEGORY', () => { + it('should update a category', () => { + const type = types.UPDATE_CATEGORY + const category = initialState[0] + const payload = { ...category, name: 'cat 1' } + expect(budgetReducer(undefined, { type, payload })).toEqual({ + ...initialState, + categoriesById: { + ...initialState.categoriesById, + [payload.id]: payload + }, + categoryTree: generateCategoryTree({ + ...initialState.categoriesById, + [payload.id]: payload + }) + }) + }) }) - it('should handle LOAD_BUDGET with no existing data', () => { - const type = types.LOAD_BUDGET - const payload = null - expect(budgetReducer(undefined, { type, payload })).toEqual(initialState) + describe('DELETE_CATEGORY', () => { + it('should delete a group', () => { + const type = types.DELETE_CATEGORY + const categoriesById = { + 1: { id: 1, name: 'group 1' }, + 2: { id: 2, name: 'group 2 ' }, + 3: { id: 3, name: 'cat 1', parentId: 1 }, + 4: { id: 4, name: 'cat 1', parentId: 2 } + } + const state = { + categoriesById, + categoryTree: generateCategoryTree(categoriesById), + rules: { + a: { categoryId: 1 }, + b: { categoryId: 2 } + } + } + + expect(budgetReducer(state, { type, payload: 1 })).toEqual({ + ...state, + categoriesById: { + 2: { id: 2, name: 'group 2 ' }, + 4: { id: 4, name: 'cat 1', parentId: 2 } + }, + categoryTree: generateCategoryTree({ + 2: { id: 2, name: 'group 2 ' }, + 4: { id: 4, name: 'cat 1', parentId: 2 } + }), + rules: { b: { categoryId: 2 } } + }) + }) + + it('should delete a category', () => { + const type = types.DELETE_CATEGORY + const categoryId = initialState.categoryTree[0].options[0].id + expect(initialState.categoriesById[categoryId].parentId === undefined) + + const { [categoryId]: _, ...rest } = initialState.categoriesById + expect(budgetReducer(undefined, { type, payload: categoryId })).toEqual({ + ...initialState, + categoriesById: rest, + categoryTree: generateCategoryTree(rest) + }) + }) }) describe('CREATE_EXACT_RULE', () => { it('should create a new rule', () => { const type = types.CREATE_EXACT_RULE - const payload = { match: 'Shopping Mart', category: 'Groceries' } + const payload = { match: 'Shopping Mart', categoryId: 1 } expect(budgetReducer(initialState, { type, payload })).toEqual({ ...initialState, rules: { [payload.match]: { - category: payload.category, + categoryId: payload.categoryId, count: 0, type: 'exact_match' } @@ -43,11 +180,11 @@ describe('budget reducer', () => { it('should update an existing rule', () => { const type = types.CREATE_EXACT_RULE - const payload = { match: 'Shopping Mart', category: 'Groceries' } + const payload = { match: 'Shopping Mart', categoryId: 1 } expect(budgetReducer({ rules: { 'Shopping Mart': 'b' } }, { type, payload })).toEqual({ rules: { [payload.match]: { - category: payload.category, + categoryId: payload.categoryId, count: 0, type: 'exact_match' } diff --git a/src/store/budget/actions.js b/src/store/budget/actions.js index cf2bc22..01fcd3e 100644 --- a/src/store/budget/actions.js +++ b/src/store/budget/actions.js @@ -1,11 +1,63 @@ +import uuid from 'uuid/v4' import types from './types' +import { saveState } from '../user/actions' +import { showSnackbar } from '../settings/actions' +import { updateTransactionFieldIfMatched } from '../transactions/actions' export const loadBudget = (budget) => { return { type: types.LOAD_BUDGET, payload: budget } } -export const createExactRule = (category, match) => ( - { type: types.CREATE_EXACT_RULE, payload: { category, match } } +export const afterCategoriesChanged = () => { + saveState() +} + +export const createCategory = (category, parentId) => (dispatch) => { + dispatch({ + type: types.CREATE_CATEGORY, + payload: { + ...category, + id: uuid(), + ...(parentId === undefined ? {} : { parentId }) + } + }) + dispatch(showSnackbar({ text: `${parentId === undefined ? 'Group' : 'Category'} created`, status: 'success' })) + afterCategoriesChanged() +} + +export const updateCategory = category => (dispatch) => { + dispatch({ type: types.UPDATE_CATEGORY, payload: category }) + dispatch(showSnackbar({ + text: `${category.parentId === undefined ? 'Group' : 'Category'} updated`, + status: 'success' + })) + afterCategoriesChanged() +} + +export const deleteCategory = categoryId => (dispatch, getState) => { + const { budget } = getState() + const category = budget.categoriesById[categoryId] + let categoryIds = [category.id] + if (!('parentId' in category)) { + categoryIds = Object.values(budget.categoriesById).reduce((res, cat) => ( + cat.parentId === category.id ? [...res, cat.id] : res + ), categoryIds) + } + dispatch({ type: types.DELETE_CATEGORY, payload: category.id }) + dispatch(updateTransactionFieldIfMatched({ + fieldName: 'categoryId', + values: categoryIds, + newValue: undefined + })) + dispatch(showSnackbar({ + text: `${category.parentId === undefined ? 'Group' : 'Category'} deleted`, + status: 'success' + })) + afterCategoriesChanged() +} + +export const createExactRule = (categoryId, match) => ( + { type: types.CREATE_EXACT_RULE, payload: { categoryId, match } } ) export const deleteExactRule = match => ( diff --git a/src/store/budget/reducer.js b/src/store/budget/reducer.js index 8a76e3f..1f50033 100644 --- a/src/store/budget/reducer.js +++ b/src/store/budget/reducer.js @@ -1,56 +1,153 @@ +/* eslint-disable no-case-declarations */ +import uuid from 'uuid/v4' import types from './types' import budgetCategories from '../../data/budgetCategories' // https://projects.susielu.com // http://repec.sowi.unibe.ch/stata/palettes/index.html -const defaultColours = [ +export const defaultColours = [ '#1f78b4', '#b2df8a', '#e31a1c', '#ff7f00', '#cab2d6', '#a6cee3', '#33a02c', '#6a3d9a', '#fb9a99', '#fdbf6f', '#ffff99', '#b15928' ] -export const initialState = (() => { - const colours = {} - let count = 0 - const categories = Object.keys(budgetCategories).sort().map(category => ( - { - label: category, - options: budgetCategories[category].map((subCategory) => { - const colour = defaultColours[count % defaultColours.length] - colours[subCategory] = colour - count += 1 - return { label: subCategory, value: subCategory, colour } + +export const generateCategoryTree = (categoriesById) => { + const parents = Object.values(categoriesById).reduce((result, parentCategory) => { + if (!('parentId' in parentCategory)) { + return { + ...result, + [parentCategory.id]: { + id: parentCategory.id, + label: parentCategory.name, + options: [] + } + } + } + return result + }, {}) + Object.values(categoriesById).forEach((category) => { + if (category.parentId in parents) { + parents[category.parentId].options.push({ + id: category.id, + label: category.name, + value: category.name, + colour: category.colour }) } - )) + }) + return Object.values(parents) + .sort((a, b) => ((a.label.toLowerCase() > b.label.toLowerCase()) ? 1 : -1)) +} + +const generateInitialState = () => { + let count = 0 + const categoriesById = Object.keys(budgetCategories).reduce((result, category) => { + const parentId = uuid() + const parent = { + id: parentId, + name: category + } + const children = budgetCategories[category].reduce((res, subCategory) => { + const child = { + id: uuid(), + parentId, + name: subCategory, + colour: defaultColours[count % defaultColours.length] + } + count += 1 + return { ...res, [child.id]: child } + }, {}) + + return { + ...result, + [parent.id]: parent, + ...children + } + }, {}) + return { - categories, - colours, + categoriesById, + categoryTree: generateCategoryTree(categoriesById), rules: {} // rules have the format => match: { category: 'cat 1', type: 'exact_match', count: 0 } } -})() +} +export const initialState = generateInitialState() +let categoriesById -export default (state = initialState, action) => { - switch (action.type) { +export default (state = initialState, { type, payload }) => { + switch (type) { case types.LOAD_BUDGET: - return action.payload || initialState + return payload || initialState + case types.CREATE_CATEGORY: + let colour = null + if ('parentId' in payload) { + // Select the next colour of the category + const group = state.categoryTree.find((cat => cat.id === payload.parentId)) + const lastColour = group.options.length === 0 + ? defaultColours[defaultColours.length - 1] + : group.options[group.options.length - 1].colour + const lastColourIndex = defaultColours.indexOf(lastColour) + colour = defaultColours[(lastColourIndex + 1) % defaultColours.length] + } + categoriesById = { + ...state.categoriesById, + [payload.id]: { + ...payload, + ...(colour !== null ? { colour } : {}) + } + } + return { + ...state, + categoriesById, + categoryTree: generateCategoryTree(categoriesById) + } + case types.UPDATE_CATEGORY: + categoriesById = { ...state.categoriesById, [payload.id]: payload } + return { + ...state, + categoriesById, + categoryTree: generateCategoryTree(categoriesById) + } + case types.DELETE_CATEGORY: + // Remove the selected category + const { [payload]: category, ...rest } = state.categoriesById + // Delete all the sub categories (if they exist) + categoriesById = Object.values(rest).reduce((res, child) => { + if (child.parentId === category.id) { + return res + } + return { ...res, [child.id]: child } + }, {}) + // Delete all related rules + const rules = Object.keys(state.rules).reduce((res, match) => { + if (state.rules[match].categoryId in categoriesById) { + return { ...res, [match]: state.rules[match] } + } + return res + }, {}) + return { + ...state, + categoriesById, + categoryTree: generateCategoryTree(categoriesById), + rules + } case types.CREATE_EXACT_RULE: return { ...state, rules: { ...state.rules, - [action.payload.match]: { - category: action.payload.category, + [payload.match]: { + categoryId: payload.categoryId, type: 'exact_match', count: 0 } } } case types.DELETE_EXACT_RULE: - // eslint-disable-next-line no-case-declarations const r = Object.keys(state.rules).reduce((result, match) => { - if (match !== action.payload) { + if (match !== payload) { return { ...result, rules: { ...result.rules, [match]: state.rules[match] } @@ -61,7 +158,6 @@ export default (state = initialState, action) => { return r case types.COUNT_RULE_USAGE: // reset counters - // eslint-disable-next-line no-case-declarations const newState = Object.keys(state.rules).reduce((result, match) => ( { ...result, @@ -73,7 +169,7 @@ export default (state = initialState, action) => { ), state) // count the transactions - for (const transaction of action.payload) { + for (const transaction of payload) { if (transaction.description in newState.rules) { newState.rules[transaction.description].count += 1 } diff --git a/src/store/budget/types.js b/src/store/budget/types.js index 0b0bf93..1e614bf 100644 --- a/src/store/budget/types.js +++ b/src/store/budget/types.js @@ -1,5 +1,8 @@ export default { LOAD_BUDGET: 'LOAD_BUDGET', + CREATE_CATEGORY: 'CREATE_CATEGORY', + UPDATE_CATEGORY: 'UPDATE_CATEGORY', + DELETE_CATEGORY: 'DELETE_CATEGORY', CREATE_EXACT_RULE: 'CREATE_EXACT_RULE', DELETE_EXACT_RULE: 'DELETE_EXACT_RULE', COUNT_RULE_USAGE: 'COUNT_RULE_USAGE' diff --git a/src/store/transactions/CsvParsers/CsvParser.js b/src/store/transactions/CsvParsers/CsvParser.js index 8d051a8..a7f8cc4 100644 --- a/src/store/transactions/CsvParsers/CsvParser.js +++ b/src/store/transactions/CsvParsers/CsvParser.js @@ -43,8 +43,8 @@ const TRANSACTION_FIELDS = { // The base class for all CSV parsers export default class CsvParser { - constructor(transactionRules) { - this._transactionRules = transactionRules + constructor(budgetRules) { + this._budgetRules = budgetRules this._csvData = [] this._csvHeader = [] this._columnsCount = 0 @@ -180,23 +180,24 @@ export default class CsvParser { this._csvData.forEach((row, index) => { if (this._noHeaderRow || index > this._firstRowIndex) { - const amount = this.readAmount(row, columns) - const description = this.readDescription(row, columns) - const category = this._transactionRules[description] - const createdAt = this.dateFromString(row[columns.createdAt]) - const errors = [] - if (typeof amount !== 'number') { - errors.push('Could not read the amount') - } else if (createdAt === null) { - errors.push(`Invalid date. Expecting format '${this._dateFormat}'`) + const transaction = { + amount: this.readAmount(row, columns), + description: this.readDescription(row, columns), + createdAt: this.dateFromString(row[columns.createdAt]), + errors: [] } - transactions.push({ - amount, - description, - category, - createdAt, - errors - }) + if ( + transaction.description in this._budgetRules + && 'categoryId' in this._budgetRules[transaction.description] + ) { + transaction.categoryId = this._budgetRules[transaction.description].categoryId + } + if (typeof transaction.amount !== 'number') { + transaction.errors.push('Could not read the amount') + } else if (transaction.createdAt === null) { + transaction.errors.push(`Invalid date. Expecting format '${this._dateFormat}'`) + } + transactions.push(transaction) } }) return { transactions, errors: this._errors } diff --git a/src/store/transactions/__tests__/actions.test.js b/src/store/transactions/__tests__/actions.test.js index 45cdfce..7bf13c7 100644 --- a/src/store/transactions/__tests__/actions.test.js +++ b/src/store/transactions/__tests__/actions.test.js @@ -8,8 +8,6 @@ import { initialState as settingsInitialState } from '../../settings/reducer' import { initialState as exchangeRatesInitialState } from '../../exchangeRates/reducer' import { initialState as budgetInitialState } from '../../budget/reducer' -jest.mock('uuid/v4', () => jest.fn(() => 1)) - beforeEach(() => { jest.resetModules() jest.clearAllMocks() @@ -52,13 +50,13 @@ describe('transactions actions', () => { budget: budgetInitialState }) - await store.dispatch(actions.createTransaction(account, transaction)) + const id = await store.dispatch(actions.createTransaction(account, transaction)) expect(store.getActions()).toEqual([ { type: 'CREATE_TRANSACTION', payload: { ...transaction, - id: 1, + id, createdAt: transaction.createdAt + 1000 } }, { @@ -94,23 +92,23 @@ describe('transactions actions', () => { exchangeRates: exchangeRatesInitialState, budget: budgetInitialState }) - const category = 'Groceries' - await store.dispatch(actions.createTransaction( + const categoryId = budgetInitialState.categoryTree[0].options[0].id + const id = await store.dispatch(actions.createTransaction( account, - { ...transaction, category } + { ...transaction, categoryId } )) expect(store.getActions()).toEqual([ { type: 'CREATE_TRANSACTION', payload: { ...transaction, - id: 1, - category, + id, + categoryId, createdAt: transaction.createdAt + 1000 } }, { type: 'CREATE_EXACT_RULE', - payload: { category, match: transaction.description } + payload: { categoryId, match: transaction.description } }, { type: 'APPLY_EXACT_RULE', payload: { @@ -179,14 +177,14 @@ describe('transactions actions', () => { exchangeRates: exchangeRatesInitialState, budget: budgetInitialState }) - const category = 'Groceries' + const categoryId = budgetInitialState.categoryTree[0].options[0].id expect(transaction.category).toBeUndefined() await store.dispatch(actions.updateTransaction( account, { ...transaction, id: 1, - category + categoryId } )) expect(store.getActions()).toEqual([ @@ -195,12 +193,12 @@ describe('transactions actions', () => { payload: { ...transaction, id: 1, - category, + categoryId, createdAt: transaction.createdAt + 1000 } }, { type: 'CREATE_EXACT_RULE', - payload: { category, match: transaction.description } + payload: { categoryId, match: transaction.description } }, { type: 'APPLY_EXACT_RULE', payload: { @@ -227,10 +225,10 @@ describe('transactions actions', () => { it('should update a transaction and remove a category', async () => { const mockStore = configureMockStore([thunk]) - const category = 'Groceries' + const categoryId = budgetInitialState.categoryTree[0].options[0].id const store = mockStore({ accounts: accountsInitialState, - transactions: { list: [{ ...transaction, id: 1, category }], transactionsInitialState }, + transactions: { list: [{ ...transaction, id: 1, categoryId }], transactionsInitialState }, settings: settingsInitialState, exchangeRates: exchangeRatesInitialState, budget: budgetInitialState @@ -241,7 +239,7 @@ describe('transactions actions', () => { { ...transaction, id: 1, - category: undefined + categoryId: undefined } )) expect(store.getActions()).toEqual([ @@ -250,7 +248,7 @@ describe('transactions actions', () => { payload: { ...transaction, id: 1, - category: undefined, + categoryId: undefined, createdAt: transaction.createdAt + 1000 } }, { @@ -265,7 +263,7 @@ describe('transactions actions', () => { }, { // NOTE: the transaction is not actually saved on the moch store so we don't see it here type: 'COUNT_RULE_USAGE', - payload: [{ ...transaction, id: 1, category }] + payload: [{ ...transaction, id: 1, categoryId }] }, { // NOTE: UPDATE_ACCOUNT is called but account balance is not changed // because test library doesn't actually update the store @@ -282,10 +280,10 @@ describe('transactions actions', () => { it('should update a transaction and change a category', async () => { const mockStore = configureMockStore([thunk]) - const category = 'Groceries' + const categoryId = budgetInitialState.categoryTree[0].options[0].id const store = mockStore({ accounts: accountsInitialState, - transactions: { list: [{ ...transaction, id: 1, category: 'Old category' }], transactionsInitialState }, + transactions: { list: [{ ...transaction, id: 1, categoryId: 'Old id' }], transactionsInitialState }, settings: settingsInitialState, exchangeRates: exchangeRatesInitialState, budget: budgetInitialState @@ -296,7 +294,7 @@ describe('transactions actions', () => { { ...transaction, id: 1, - category + categoryId } )) expect(store.getActions()).toEqual([ @@ -305,12 +303,12 @@ describe('transactions actions', () => { payload: { ...transaction, id: 1, - category, + categoryId, createdAt: transaction.createdAt + 1000 } }, { type: 'CREATE_EXACT_RULE', - payload: { category, match: transaction.description } + payload: { categoryId, match: transaction.description } }, { type: 'APPLY_EXACT_RULE', payload: { @@ -320,7 +318,7 @@ describe('transactions actions', () => { }, { // NOTE: the transaction is not actually saved on the moch store so we don't see it here type: 'COUNT_RULE_USAGE', - payload: [{ ...transaction, id: 1, category: 'Old category' }] + payload: [{ ...transaction, id: 1, categoryId: 'Old id' }] }, { // NOTE: UPDATE_ACCOUNT is called but account balance is not changed // because test library doesn't actually update the store diff --git a/src/store/transactions/__tests__/reducers.test.js b/src/store/transactions/__tests__/reducers.test.js index 03f7b92..d2ddb43 100644 --- a/src/store/transactions/__tests__/reducers.test.js +++ b/src/store/transactions/__tests__/reducers.test.js @@ -9,105 +9,147 @@ const transaction = { } describe('transaction reducer', () => { - it('should return initial state', () => { - expect(transactionReducer(undefined, {})).toEqual(initialState) + describe('initialState', () => { + it('should return initial state', () => { + expect(transactionReducer(undefined, {})).toEqual(initialState) + }) }) - it('should handle LOAD_TRANSACTIONS', () => { - const type = types.LOAD_TRANSACTIONS - const payload = [transaction] - expect(transactionReducer(undefined, { type, payload })).toEqual(payload) - }) + describe('LOAD_TRANSACTIONS', () => { + it('should handle LOAD_TRANSACTIONS', () => { + const type = types.LOAD_TRANSACTIONS + const payload = [transaction] + expect(transactionReducer(undefined, { type, payload })).toEqual(payload) + }) - it('should handle LOAD_TRANSACTIONS with no existing transactions', () => { - const type = types.LOAD_TRANSACTIONS - const payload = null - expect(transactionReducer(undefined, { type, payload })).toEqual(initialState) + it('should handle LOAD_TRANSACTIONS with no existing transactions', () => { + const type = types.LOAD_TRANSACTIONS + const payload = null + expect(transactionReducer(undefined, { type, payload })).toEqual(initialState) + }) }) - it('should handle CREATE_TRANSACTION with no existing transactions', () => { - const type = types.CREATE_TRANSACTION - const payload = transaction - expect(transactionReducer(undefined, { type, payload })).toEqual({ ...initialState, list: [payload] }) + describe('CREATE_TRANSACTION', () => { + it('should handle CREATE_TRANSACTION with no existing transactions', () => { + const type = types.CREATE_TRANSACTION + const payload = transaction + expect(transactionReducer(undefined, { type, payload })).toEqual({ ...initialState, list: [payload] }) + }) + + it('should handle CREATE_TRANSACTION with existing transactions', () => { + const type = types.CREATE_TRANSACTION + const state = { + ...initialState, + list: [{ + institution: 'TD', + account: 'RRSP', + type: 'buy', + ticker: 'VCE.TO', + shares: '2', + bookValue: '2', + createdAt: Date.now() + }] + } + const payload = transaction + expect(transactionReducer(state, { type, payload })).toEqual({ ...state, list: [...state.list, payload] }) + }) }) - it('should handle CREATE_TRANSACTION with existing transactions', () => { - const type = types.CREATE_TRANSACTION - const state = { - ...initialState, - list: [{ - institution: 'TD', - account: 'RRSP', - type: 'buy', - ticker: 'VCE.TO', - shares: '2', - bookValue: '2', - createdAt: Date.now() - }] - } - const payload = transaction - expect(transactionReducer(state, { type, payload })).toEqual({ ...state, list: [...state.list, payload] }) + describe('UPDATE_TRANSACTION', () => { + it('should handle UPDATE_TRANSACTION', () => { + const type = types.UPDATE_TRANSACTION + const state = { + ...initialState, + list: [{ + ...transaction, id: 1, shares: '2', bookValue: '2' + }] + } + const payload = { ...transaction, id: 1 } + expect(transactionReducer(state, { type, payload })).toEqual({ ...state, list: [payload] }) + }) }) - it('should handle UPDATE_TRANSACTION', () => { - const type = types.UPDATE_TRANSACTION - const state = { - ...initialState, - list: [{ - ...transaction, id: 1, shares: '2', bookValue: '2' - }] - } - const payload = { ...transaction, id: 1 } - expect(transactionReducer(state, { type, payload })).toEqual({ ...state, list: [payload] }) + describe('UPATE_TRANSACTION_FIELD_IF_MATCHED', () => { + it('should upate single field in transactions', () => { + const type = types.UPATE_TRANSACTION_FIELD_IF_MATCHED + const payload = { + fieldName: 'categoryId', + values: [1, 2], + newValue: undefined + } + const state = { + list: [ + transaction, + { id: 2, categoryId: 1 }, + { id: 3, categoryId: 2 }, + { id: 4, categoryId: undefined }, + { id: 5, categoryId: 3 } + ] + } + expect(transactionReducer(state, { type, payload })).toEqual({ + ...state, + list: [ + transaction, + { id: 2, categoryId: undefined }, + { id: 3, categoryId: undefined }, + { id: 4, categoryId: undefined }, + { id: 5, categoryId: 3 } + ] + }) + }) }) - it('should handle DELETE_TRANSACTIONS', () => { - const type = types.DELETE_TRANSACTIONS - const state = { - ...initialState, - list: [transaction] - } - const payload = [state.list[0].id] - expect(transactionReducer(state, { type, payload })).toEqual(initialState) + describe('DELETE_TRANSACTIONS', () => { + it('should handle DELETE_TRANSACTIONS', () => { + const type = types.DELETE_TRANSACTIONS + const state = { + ...initialState, + list: [transaction] + } + const payload = [state.list[0].id] + expect(transactionReducer(state, { type, payload })).toEqual(initialState) + }) }) - it('should handle ADD_TRANSACTIONS to existing transactions', () => { - const type = types.ADD_TRANSACTIONS - const state = { - ...initialState, - list: [{ - id: 1, + describe('ADD_TRANSACTIONS', () => { + it('should handle ADD_TRANSACTIONS to existing transactions', () => { + const type = types.ADD_TRANSACTIONS + const state = { + ...initialState, + list: [{ + id: 1, + accountId: 1, + amount: 2, + createdAt: Date.now() + }] + } + const payload = [{ + id: 2, accountId: 1, amount: 2, createdAt: Date.now() + }, { + id: 3, + accountId: 1, + amount: 3, + createdAt: Date.now() }] - } - const payload = [{ - id: 2, - accountId: 1, - amount: 2, - createdAt: Date.now() - }, { - id: 3, - accountId: 1, - amount: 3, - createdAt: Date.now() - }] - expect(transactionReducer(state, { type, payload })).toEqual({ - ...state, - list: [...state.list, ...payload] + expect(transactionReducer(state, { type, payload })).toEqual({ + ...state, + list: [...state.list, ...payload] + }) }) }) describe('APPLY_EXACT_RULE', () => { - it('should apply an exact rule', () => { + it('should add a categoryId when applying an exact rule', () => { const type = types.APPLY_EXACT_RULE const payload = { - match: 'Shopping Mart', + match: transaction.description, rules: { - a: { category: 'b' }, - [transaction.description]: { category: 'xyz' }, - other: { category: 'something else' } + a: { categoryId: 'b' }, + [transaction.description]: { categoryId: 'xyz' }, + other: { categoryId: 'something else' } } } const state = { @@ -119,7 +161,31 @@ describe('transaction reducer', () => { expect(transactionReducer(state, { type, payload })).toEqual({ ...state, list: [ - { ...transaction, category: 'xyz' }, + { ...transaction, categoryId: 'xyz' }, + { id: 2, description: 'abc' } + ] + }) + }) + + it('should remove categoryId if rule does not exist', () => { + const type = types.APPLY_EXACT_RULE + const payload = { + match: transaction.description, + rules: { + a: { categoryId: 'b' }, + other: { categoryId: 'something else' } + } + } + const state = { + list: [ + { ...transaction, categoryId: 'xyz' }, + { id: 2, description: 'abc' } + ] + } + expect(transactionReducer(state, { type, payload })).toEqual({ + ...state, + list: [ + transaction, { id: 2, description: 'abc' } ] }) diff --git a/src/store/transactions/actions.js b/src/store/transactions/actions.js index c640c92..44072bc 100644 --- a/src/store/transactions/actions.js +++ b/src/store/transactions/actions.js @@ -19,22 +19,24 @@ export const applyExactRule = ({ match, rules }) => ( export const createTransaction = (account, transaction) => async (dispatch, getState) => { await dispatch(fetchExchangeRates([account.currency], transaction.createdAt)) + const id = uuid() dispatch({ type: types.CREATE_TRANSACTION, payload: { ...transaction, - id: uuid(), + id, accountId: account.id, createdAt: transaction.createdAt + 1000 // Plus 1 second } }) - if (transaction.category !== undefined) { - dispatch(createExactRule(transaction.category, transaction.description)) + if (transaction.categoryId !== undefined) { + dispatch(createExactRule(transaction.categoryId, transaction.description)) } dispatch(applyExactRule({ match: transaction.description, rules: getState().budget.rules })) dispatch(countRuleUsage()) await dispatch(afterTransactionsChanged(account)) dispatch(showSnackbar({ text: 'Transaction created', status: 'success' })) + return id } export const updateTransaction = (account, transaction) => async (dispatch, getState) => { @@ -47,11 +49,11 @@ export const updateTransaction = (account, transaction) => async (dispatch, getS } }) - if (transaction.category !== oldTransaction.category) { - if (transaction.category === undefined) { + if (transaction.categoryId !== oldTransaction.categoryId) { + if (transaction.categoryId === undefined) { dispatch(deleteExactRule(transaction.description)) } else { - dispatch(createExactRule(transaction.category, transaction.description)) + dispatch(createExactRule(transaction.categoryId, transaction.description)) } dispatch(applyExactRule({ match: transaction.description, rules: getState().budget.rules })) dispatch(countRuleUsage()) @@ -60,6 +62,10 @@ export const updateTransaction = (account, transaction) => async (dispatch, getS dispatch(showSnackbar({ text: 'Transaction updated', status: 'success' })) } +export const updateTransactionFieldIfMatched = ({ fieldName, values, newValue }) => ( + { type: types.UPATE_TRANSACTION_FIELD_IF_MATCHED, payload: { fieldName, values, newValue } } +) + export const deleteTransactions = (account, transactionIds, options = { skipAfterChange: false }) => { const { skipAfterChange } = options return async (dispatch) => { diff --git a/src/store/transactions/reducer.js b/src/store/transactions/reducer.js index 6649f6f..0863d39 100644 --- a/src/store/transactions/reducer.js +++ b/src/store/transactions/reducer.js @@ -1,3 +1,4 @@ +/* eslint-disable no-case-declarations */ import _ from 'lodash' import types from './types' @@ -5,40 +6,40 @@ export const initialState = { list: [] } -export default (state = initialState, action) => { +export default (state = initialState, { type, payload }) => { let index = null const findTransactionById = id => ( _.findIndex(state.list, transaction => transaction.id === id) ) - switch (action.type) { + switch (type) { case types.LOAD_TRANSACTIONS: - return action.payload || initialState + return payload || initialState case types.CREATE_TRANSACTION: return { ...state, - list: [...state.list, action.payload] + list: [...state.list, payload] } case types.UPDATE_TRANSACTION: - index = findTransactionById(action.payload.id) + index = findTransactionById(payload.id) return { ...state, - list: [...state.list.slice(0, index), action.payload, ...state.list.slice(index + 1)] + list: [...state.list.slice(0, index), payload, ...state.list.slice(index + 1)] } case types.DELETE_TRANSACTIONS: return { ...state, - list: state.list.filter(transaction => action.payload.indexOf(transaction.id) === -1) + list: state.list.filter(transaction => payload.indexOf(transaction.id) === -1) } case types.ADD_TRANSACTIONS: return { ...state, - list: [...state.list, ...action.payload] + list: [...state.list, ...payload] } case types.APPLY_EXACT_RULE: // eslint-disable-next-line no-case-declarations - const { match, rules } = action.payload + const { match, rules } = payload return { ...state, list: state.list.reduce((result, transaction) => { @@ -47,13 +48,28 @@ export default (state = initialState, action) => { ...result, { ...transaction, - category: transaction.description in rules ? rules[transaction.description].category : undefined + categoryId: transaction.description in rules + ? rules[transaction.description].categoryId + : undefined } ] } return [...result, transaction] }, []) } + case types.UPATE_TRANSACTION_FIELD_IF_MATCHED: + return { + ...state, + list: state.list.reduce((result, transaction) => ([ + ...result, + { + ...transaction, + categoryId: payload.values.includes(transaction[payload.fieldName]) + ? payload.newValue + : transaction[payload.fieldName] + } + ]), []) + } default: return state } diff --git a/src/store/transactions/types.js b/src/store/transactions/types.js index 919eba3..e62a088 100644 --- a/src/store/transactions/types.js +++ b/src/store/transactions/types.js @@ -2,6 +2,7 @@ export default { LOAD_TRANSACTIONS: 'LOAD_TRANSACTIONS', CREATE_TRANSACTION: 'CREATE_TRANSACTION', UPDATE_TRANSACTION: 'UPDATE_TRANSACTION', + UPATE_TRANSACTION_FIELD_IF_MATCHED: 'UPATE_TRANSACTION_FIELD_IF_MATCHED', ADD_TRANSACTIONS: 'ADD_TRANSACTIONS', DELETE_TRANSACTIONS: 'DELETE_TRANSACTIONS', APPLY_EXACT_RULE: 'APPLY_EXACT_RULE' diff --git a/src/store/user/__tests__/actions.test.js b/src/store/user/__tests__/actions.test.js index f3e5eda..4e390f0 100644 --- a/src/store/user/__tests__/actions.test.js +++ b/src/store/user/__tests__/actions.test.js @@ -167,13 +167,7 @@ describe('user actions', () => { { type: 'LOAD_ACCOUNTS', payload: undefined }, { type: 'LOAD_TRANSACTIONS', payload: undefined }, { type: 'LOAD_EXCHANGE_RATES', payload: undefined }, - { - type: 'LOAD_BUDGET', - payload: { - categories: budgetInitialState.categories, - colours: budgetInitialState.colours - } - }, + { type: 'LOAD_BUDGET', payload: undefined }, { type: 'HIDE_OVERLAY' } ]) done() diff --git a/src/store/user/actions.js b/src/store/user/actions.js index f37ba8a..a941700 100644 --- a/src/store/user/actions.js +++ b/src/store/user/actions.js @@ -42,11 +42,7 @@ export const loadUserData = () => (dispatch, getState) => { dispatch(loadAccounts((state || {}).accounts)) dispatch(loadTransactions((state || {}).transactions)) dispatch(loadExchangeRates((state || {}).exchangeRates)) - dispatch(loadBudget({ - ...(state || {}).budget, - categories: budgetInitialState.categories, - colours: budgetInitialState.colours - })) + dispatch(loadBudget((state || {}).budget)) dispatch(hideOverlay()) }).catch((error) => { throw error @@ -87,7 +83,7 @@ export const handleBlockstackLogin = () => (dispatch) => { } export const resetState = () => (dispatch, getState) => { - const { settings } = getState().settings + const { settings } = getState() if (settings === undefined) { dispatch(loadSettings(settingsInitialState)) } else {
+ You can keep track of all the accounts you have from any institution. +