diff --git a/package.json b/package.json index d85311f7..b8a33c29 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,7 @@ "reselect": "4.0.0", "resolve": "1.20.0", "resolve-url-loader": "3.1.2", + "rrule": "^2.6.8", "sass": "1.32.8", "selector-action": "1.2.0", "semver": "7.3.5", diff --git a/src/components/Expenses/NewExpenseDialog.tsx b/src/components/Expenses/NewExpenseDialog.tsx index 1bf82230..a611f38e 100644 --- a/src/components/Expenses/NewExpenseDialog.tsx +++ b/src/components/Expenses/NewExpenseDialog.tsx @@ -40,7 +40,7 @@ export interface WithConnectionPropTypes extends PropTypes { fundingSchedules: Map; } -export interface State { +interface State { step: NewExpenseStep; canNextStep: boolean; } diff --git a/src/components/FundingSchedules/FundingScheduleList.tsx b/src/components/FundingSchedules/FundingScheduleList.tsx new file mode 100644 index 00000000..d23ef905 --- /dev/null +++ b/src/components/FundingSchedules/FundingScheduleList.tsx @@ -0,0 +1,74 @@ +import { Button, List, ListItem, ListItemText } from "@material-ui/core"; +import NewFundingScheduleDialog from "components/FundingSchedules/NewFundingScheduleDialog"; +import FundingSchedule from "data/FundingSchedule"; +import { Map } from 'immutable'; +import React, { Component } from "react"; +import { connect } from "react-redux"; + +export interface PropTypes { + onHide: { (): void } +} + +interface WithConnectionPropTypes extends PropTypes { + fundingSchedules: Map; +} + + +interface State { + newFundingScheduleDialogOpen: boolean; +} + +export class FundingScheduleList extends Component { + + state = { + newFundingScheduleDialogOpen: false, + }; + + openNewFundingScheduleDialog = () => { + return this.setState({ + newFundingScheduleDialogOpen: true, + }); + }; + + closeFundingScheduleDialog = () => { + return this.setState({ + newFundingScheduleDialogOpen: false, + }); + }; + + render() { + const { fundingSchedules, onHide } = this.props; + return ( +
+ +
+ + +
+ + { + fundingSchedules.map(schedule => ( + + + { schedule.name } + + + )).toArray() + } + +
+ ) + } +} + +export default connect( + state => ({ + fundingSchedules: Map(), + }), + {} +)(FundingScheduleList); diff --git a/src/components/FundingSchedules/NewFundingScheduleDialog.tsx b/src/components/FundingSchedules/NewFundingScheduleDialog.tsx new file mode 100644 index 00000000..cb1f5805 --- /dev/null +++ b/src/components/FundingSchedules/NewFundingScheduleDialog.tsx @@ -0,0 +1,255 @@ +import MomentUtils from "@date-io/moment"; +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + Step, + StepContent, + StepLabel, + Stepper, + TextField +} from "@material-ui/core"; +import { KeyboardDatePicker, MuiPickersUtilsProvider } from '@material-ui/pickers'; +import Recurrence from "components/Recurrence/Recurrence"; +import { RecurrenceList } from "components/Recurrence/RecurrenceList"; +import FundingSchedule from "data/FundingSchedule"; +import { Formik, FormikErrors } from "formik"; +import moment from "moment"; +import React, { Component, Fragment } from "react"; +import { connect } from "react-redux"; +import { getSelectedBankAccountId } from "shared/bankAccounts/selectors/getSelectedBankAccountId"; +import request from "shared/util/request"; + +enum NewFundingScheduleStep { + Name, + Date, + Recurrence, +} + +export interface PropTypes { + onClose: { (): void }; + isOpen: boolean; +} + +interface WithConnectionPropTypes extends PropTypes { + bankAccountId: number; +} + +interface State { + step: NewFundingScheduleStep; +} + +interface newFundingScheduleForm { + name: string; + nextOccurrence: moment.Moment; + recurrenceRule: Recurrence; +} + +const initialValues: newFundingScheduleForm = { + name: '', + nextOccurrence: moment(), + recurrenceRule: new Recurrence(), +}; + +export class NewFundingScheduleDialog extends Component { + + state = { + step: NewFundingScheduleStep.Name, + }; + + validateInput = (values: newFundingScheduleForm): FormikErrors => { + return {}; + }; + + submit = (values: newFundingScheduleForm, { setSubmitting }) => { + const { bankAccountId } = this.props; + + const newFundingSchedule = new FundingSchedule({ + bankAccountId: bankAccountId, + name: values.name, + description: values.recurrenceRule.name, + nextOccurrence: values.nextOccurrence, + rule: values.recurrenceRule.ruleString(), + }); + + console.log(newFundingSchedule); + + return request().post(`/bank_accounts/${bankAccountId}/funding_schedules`, newFundingSchedule) + .then(result => { + + }) + .catch(error => { + + }); + }; + + + nextStep = () => { + return this.setState(prevState => ({ + step: Math.min(NewFundingScheduleStep.Recurrence, prevState.step + 1), + })); + }; + + previousStep = () => { + return this.setState(prevState => ({ + step: Math.max(NewFundingScheduleStep.Name, prevState.step - 1), + })); + }; + + renderActions = (isSubmitting: boolean, submitForm: { (): Promise }) => { + const { onClose } = this.props; + const { step } = this.state; + + const cancelButton = ( + + ); + + const previousButton = ( + + ); + + const nextButton = ( + + ); + + const submitButton = ( + + ); + + switch (step) { + case NewFundingScheduleStep.Name: + return ( + + { cancelButton } + { nextButton } + + ); + case NewFundingScheduleStep.Recurrence: + return ( + + { previousButton } + { submitButton } + + ); + default: + return ( + + { previousButton } + { nextButton } + + ); + } + }; + + render() { + const { onClose, isOpen } = this.props; + const { step } = this.state; + + return ( + + { ({ + values, + errors, + touched, + handleChange, + handleBlur, + handleSubmit, + setFieldValue, + isSubmitting, + submitForm, + }) => ( +
+ + + + Create a new funding schedule + + + + Funding schedules let us know when you will get paid so we can automatically allocate money towards + your budgets. + +
+ + + What do you want to call this funding schedule? + + + + + + When do you get paid next? + + setFieldValue('nextOccurrence', value.startOf('day')) } + KeyboardButtonProps={ { + 'aria-label': 'change date', + } } + /> + + + + How often do you get paid? + + { (step === NewFundingScheduleStep.Recurrence || values.nextOccurrence) && + setFieldValue('recurrenceRule', value) }/> + } + + + +
+
+ + { this.renderActions(isSubmitting, submitForm) } + +
+
+
+ ) } +
+ ) + } +} + +export default connect( + state => ({ + bankAccountId: getSelectedBankAccountId(state), + }), + {} +)(NewFundingScheduleDialog); diff --git a/src/components/Recurrence/Recurrence.ts b/src/components/Recurrence/Recurrence.ts new file mode 100644 index 00000000..a29c1660 --- /dev/null +++ b/src/components/Recurrence/Recurrence.ts @@ -0,0 +1,16 @@ +import { RRule } from 'rrule'; + +export default class Recurrence { + name: string; + rule: RRule; + + constructor(recurrence?: Partial) { + if (recurrence) { + Object.assign(this, recurrence); + } + } + + ruleString(): string { + return this.rule.toString(); + } +} diff --git a/src/components/Recurrence/RecurrenceList.tsx b/src/components/Recurrence/RecurrenceList.tsx new file mode 100644 index 00000000..9cff9a0c --- /dev/null +++ b/src/components/Recurrence/RecurrenceList.tsx @@ -0,0 +1,72 @@ +import { Checkbox, List as ListUI, ListItem, ListItemIcon, ListItemText } from "@material-ui/core"; +import getRecurrencesForDate from "components/Recurrence/getRecurrencesForDate"; +import Recurrence from "components/Recurrence/Recurrence"; + +import { List } from 'immutable'; +import moment from "moment"; +import React, { Component } from "react"; + +export interface PropTypes { + date: moment.Moment; + onChange: { (value: Recurrence): void } +} + +interface State { + rules: List; + selectedIndex?: number; +} + +// RecurrenceList generates a list of possible recurrence rules based on the provided date. When a recurrence is +// selected the onChange function will be called with a string value representing an RRule. +export class RecurrenceList extends Component { + + state = { + selectedIndex: null, + rules: List(), + }; + + componentDidMount() { + const { date } = this.props; + this.setState({ + rules: getRecurrencesForDate(date) + }); + } + + selectItem = (index: number) => () => { + const { onChange } = this.props; + const { rules } = this.state; + + return this.setState({ + selectedIndex: index, + }, () => onChange(rules.get(index))); + }; + + renderItems = () => { + const { rules, selectedIndex } = this.state; + + return rules.map((rule, index) => ( + + + + + + { rule.name } + + + )); + }; + + render() { + + return ( + + { this.renderItems() } + + ); + } +} diff --git a/src/components/Recurrence/getRecurrencesForDate.ts b/src/components/Recurrence/getRecurrencesForDate.ts new file mode 100644 index 00000000..94a6c7ce --- /dev/null +++ b/src/components/Recurrence/getRecurrencesForDate.ts @@ -0,0 +1,136 @@ +import Recurrence from "components/Recurrence/Recurrence"; +import moment from "moment"; +import RRule, { Weekday } from "rrule"; +import { List } from 'immutable'; + +export default function getRecurrencesForDate(date: moment.Moment): List { + const input = date.clone().startOf('day'); + const endOfMonth = input.clone().endOf('month').startOf('day'); + const startOfMonth = input.clone().startOf('month').startOf('day'); + const isStartOfMonth = input.unix() === startOfMonth.unix(); + const isEndOfMonth = input.unix() === endOfMonth.unix(); + + const weekdayString = input.format('dddd'); + + const ruleWeekday = getRuleDayOfWeek(input); + + const dayStr = isEndOfMonth ? ' last day of the month' : ordinalSuffixOf(input.date()); + + let rules = [ + new Recurrence({ + name: `Every ${ weekdayString }`, + rule: new RRule({ + freq: RRule.WEEKLY, + interval: 1, + byweekday: [ruleWeekday], + }), + }), + new Recurrence({ + name: `Every other ${ weekdayString }`, + rule: new RRule({ + freq: RRule.WEEKLY, + interval: 2, + byweekday: [ruleWeekday], + }), + }), + new Recurrence({ + name: `Every month on the ${ dayStr }`, + rule: new RRule({ + freq: RRule.MONTHLY, + interval: 1, + bymonthday: input.date(), + }), + }), + new Recurrence({ + name: `Every other month on the ${ dayStr }`, + rule: new RRule({ + freq: RRule.MONTHLY, + interval: 2, + bymonthday: input.date(), + }), + }), + new Recurrence({ + name: `Every 3 months (quarter) on the ${ dayStr }`, + rule: new RRule({ + freq: RRule.MONTHLY, + interval: 3, + bymonthday: input.date(), + }), + }), + new Recurrence({ + name: `Every 6 months on the ${ dayStr }`, + rule: new RRule({ + freq: RRule.MONTHLY, + interval: 3, + bymonthday: input.date(), + }), + }), + new Recurrence({ + name: `Every year on the ${ ordinalSuffixOf(input.date()) } of ${ input.format('MMMM') }`, + rule: new RRule({ + freq: RRule.YEARLY, + interval: 3, + bymonthday: input.date(), + }), + }), + ]; + + if (isStartOfMonth) { + rules.push(new Recurrence({ + name: `On the 1st and 15th of every month`, + rule: new RRule({ + freq: RRule.MONTHLY, + interval: 1, + bymonthday: [1, 15], + }) + })); + } + + if (isEndOfMonth) { + rules.push(new Recurrence({ + name: `On the 15th and last day of every month`, + rule: new RRule({ + freq: RRule.MONTHLY, + interval: 1, + bymonthday: [15, -1], + }) + })); + } + + return List(rules); +} + +function getRuleDayOfWeek(date: moment.Moment): Weekday { + switch (date.format('dddd')) { + case 'Monday': + return RRule.MO; + case 'Tuesday': + return RRule.TU; + case 'Wednesday': + return RRule.WE; + case 'Thursday': + return RRule.TH; + case 'Friday': + return RRule.FR; + case 'Saturday': + return RRule.SA; + case 'Sunday': + return RRule.SU; + default: + return RRule.SU; + } +} + +function ordinalSuffixOf(i) { + let j = i % 10, k = i % 100; + if (j === 1 && k !== 11) { + return i + "st"; + } + if (j === 2 && k !== 12) { + return i + "nd"; + } + if (j === 3 && k !== 13) { + return i + "rd"; + } + return i + "th"; +} diff --git a/src/components/Transactions/spec/TransactionDetail.spec.js b/src/components/Transactions/spec/TransactionDetail.spec.js index 622f5b85..8b59aa24 100644 --- a/src/components/Transactions/spec/TransactionDetail.spec.js +++ b/src/components/Transactions/spec/TransactionDetail.spec.js @@ -18,4 +18,14 @@ describe('transaction detail view', () => { // Make sure it's actually there. expect(document.querySelector('.transaction-detail')).not.toBeEmptyDOMElement(); }); + + it('will not render', () => { + render(); + + // Make sure it's actually there. + expect(document.querySelector('.transaction-detail')).toBeNull(); + }) }); diff --git a/src/data/FundingSchedule.ts b/src/data/FundingSchedule.ts index 544dada3..ae1ffece 100644 --- a/src/data/FundingSchedule.ts +++ b/src/data/FundingSchedule.ts @@ -1,16 +1,6 @@ import { Moment } from "moment"; -export interface FundingScheduleFields { - fundingScheduleId: number; - bankAccountId: number; - name: string; - description?: string; - rule: string; - lastOccurrence?: Moment; - nextOccurrence: Moment; -} - -export default class FundingSchedule implements FundingScheduleFields { +export default class FundingSchedule { fundingScheduleId: number; bankAccountId: number; name: string; @@ -19,7 +9,7 @@ export default class FundingSchedule implements FundingScheduleFields { lastOccurrence?: Moment; nextOccurrence: Moment; - constructor(data?: FundingScheduleFields) { + constructor(data?: Partial) { if (data) { Object.assign(this, data); } diff --git a/src/shared/fundingSchedules/actions.ts b/src/shared/fundingSchedules/actions.ts index e69de29b..5bec3642 100644 --- a/src/shared/fundingSchedules/actions.ts +++ b/src/shared/fundingSchedules/actions.ts @@ -0,0 +1,50 @@ +import FundingSchedule from "data/FundingSchedule"; +import { Map } from 'immutable'; +import { Logout } from "shared/authentication/actions"; + +export enum FetchFundingSchedules { + Request, + Failure, + Success, +} + +export interface FetchFundingSchedulesRequest { + type: typeof FetchFundingSchedules.Request; +} + +export interface FetchFundingSchedulesFailure { + type: typeof FetchFundingSchedules.Failure; +} + +export interface FetchFundingSchedulesSuccess { + type: typeof FetchFundingSchedules.Success; + payload: Map>; +} + +export enum CreateFundingSchedule { + Request, + Failure, + Success, +} + +export interface CreateFundingScheduleRequest { + type: typeof CreateFundingSchedule.Request; +} + +export interface CreateFundingScheduleFailure { + type: typeof CreateFundingSchedule.Failure; +} + +export interface CreateFundingScheduleSuccess { + type: typeof CreateFundingSchedule.Success; + payload: FundingSchedule; +} + +export type FundingScheduleActions = + FetchFundingSchedulesRequest + | FetchFundingSchedulesFailure + | FetchFundingSchedulesSuccess + | CreateFundingScheduleRequest + | CreateFundingScheduleFailure + | CreateFundingScheduleSuccess + | Logout diff --git a/src/shared/fundingSchedules/actions/createFundingSchedule.ts b/src/shared/fundingSchedules/actions/createFundingSchedule.ts new file mode 100644 index 00000000..68cd1a8d --- /dev/null +++ b/src/shared/fundingSchedules/actions/createFundingSchedule.ts @@ -0,0 +1,9 @@ +import FundingSchedule from "data/FundingSchedule"; +import { Dispatch } from "redux"; + + +export default function createFundingSchedule(fundingSchedule: FundingSchedule) { + return (dispatch: Dispatch) => { + + } +} diff --git a/src/shared/fundingSchedules/reducer.ts b/src/shared/fundingSchedules/reducer.ts new file mode 100644 index 00000000..fd872d5a --- /dev/null +++ b/src/shared/fundingSchedules/reducer.ts @@ -0,0 +1,18 @@ +import { LOGOUT } from "shared/authentication/actions"; +import { CreateFundingSchedule, FundingScheduleActions } from "shared/fundingSchedules/actions"; +import FundingScheduleState from "shared/fundingSchedules/state"; + + +export default function reducer(state: FundingScheduleState = new FundingScheduleState(), action: FundingScheduleActions): FundingScheduleState { + switch (action.type) { + case CreateFundingSchedule.Success: + return { + ...state + }; + case CreateFundingSchedule.Failure: + case LOGOUT: + return new FundingScheduleState(); + default: + return state; + } +} diff --git a/src/views/ExpensesView/index.tsx b/src/views/ExpensesView/index.tsx index a0e123aa..d9a6ac23 100644 --- a/src/views/ExpensesView/index.tsx +++ b/src/views/ExpensesView/index.tsx @@ -1,5 +1,7 @@ import { Button, Card, List, Typography } from "@material-ui/core"; import NewExpenseDialog from "components/Expenses/NewExpenseDialog"; +import FundingScheduleList from "components/FundingSchedules/FundingScheduleList"; +import { NewFundingScheduleDialog } from "components/FundingSchedules/NewFundingScheduleDialog"; import Spending from "data/Spending"; import { Map } from 'immutable'; import React, { Component, Fragment } from "react"; @@ -12,6 +14,7 @@ interface PropTypes { interface State { newExpenseDialogOpen: boolean; + showFundingSchedules: boolean; selectedExpense?: number; } @@ -19,6 +22,7 @@ export class ExpensesView extends Component { state = { newExpenseDialogOpen: false, + showFundingSchedules: false, selectedExpense: null }; @@ -38,6 +42,17 @@ export class ExpensesView extends Component { ) }; + renderSideBar = () => { + const { showFundingSchedules } = this.state; + if (showFundingSchedules) { + return ( + + ); + } + + return this.renderExpenseDetailView(); + }; + renderExpenseDetailView = () => { const { selectedExpense } = this.state; @@ -72,8 +87,20 @@ export class ExpensesView extends Component { }); }; + showFundingSchedules = () => { + return this.setState({ + showFundingSchedules: true + }); + }; + + hideFundingSchedules = () => { + return this.setState({ + showFundingSchedules: false + }); + } + render() { - const { newExpenseDialogOpen } = this.state; + const { newExpenseDialogOpen, showFundingSchedules } = this.state; return ( @@ -86,9 +113,11 @@ export class ExpensesView extends Component {
- + }
- { this.renderExpenseDetailView() } + { this.renderSideBar() }
diff --git a/webpack.config.js b/webpack.config.js index 0cc76b11..9a3c148a 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -100,6 +100,7 @@ module.exports = (env, argv) => { }, modules: [path.resolve(__dirname, 'src'), 'node_modules'], }, + devtool: 'inline-source-map', devServer: { contentBase: './public', historyApiFallback: true, diff --git a/yarn.lock b/yarn.lock index 34d087d7..0f160a48 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7325,6 +7325,11 @@ lru-cache@^6.0.0: dependencies: yallist "^4.0.0" +luxon@^1.21.3: + version "1.26.0" + resolved "https://registry.yarnpkg.com/luxon/-/luxon-1.26.0.tgz#d3692361fda51473948252061d0f8561df02b578" + integrity sha512-+V5QIQ5f6CDXQpWNICELwjwuHdqeJM1UenlZWx5ujcRMc9venvluCjFb4t5NYLhb6IhkbMVOxzVuOqkgMxee2A== + lz-string@^1.4.4: version "1.4.4" resolved "https://registry.yarnpkg.com/lz-string/-/lz-string-1.4.4.tgz#c0d8eaf36059f705796e1e344811cf4c498d3a26" @@ -9916,6 +9921,15 @@ rollup@^2.25.0: optionalDependencies: fsevents "~2.3.1" +rrule@^2.6.8: + version "2.6.8" + resolved "https://registry.yarnpkg.com/rrule/-/rrule-2.6.8.tgz#c61714f246e7676e8efa16c2baabac199f20f6db" + integrity sha512-cUaXuUPrz9d1wdyzHsBfT1hptKlGgABeCINFXFvulEPqh9Np9BnF3C3lrv9uO54IIr8VDb58tsSF3LhsW+4VRw== + dependencies: + tslib "^1.10.0" + optionalDependencies: + luxon "^1.21.3" + rsvp@^4.8.4: version "4.8.5" resolved "https://registry.yarnpkg.com/rsvp/-/rsvp-4.8.5.tgz#c8f155311d167f68f21e168df71ec5b083113734"