diff --git a/packages/desktop-client/src/components/FinancesApp.tsx b/packages/desktop-client/src/components/FinancesApp.tsx index 57deef99033..4f5b0f8d269 100644 --- a/packages/desktop-client/src/components/FinancesApp.tsx +++ b/packages/desktop-client/src/components/FinancesApp.tsx @@ -43,6 +43,7 @@ import { NarrowAlternate, WideComponent } from './responsive'; import PostsOfflineNotification from './schedules/PostsOfflineNotification'; import Settings from './settings'; import Titlebar, { TitlebarProvider } from './Titlebar'; +import { TransactionEdit } from './transactions/MobileTransaction'; function NarrowNotSupported({ redirectTo = '/budget', @@ -61,6 +62,17 @@ function NarrowNotSupported({ return isNarrowWidth ? null : children; } +function WideNotSupported({ children, redirectTo = '/budget' }) { + const { isNarrowWidth } = useResponsive(); + const navigate = useNavigate(); + useEffect(() => { + if (!isNarrowWidth) { + navigate(redirectTo); + } + }, [isNarrowWidth, navigate, redirectTo]); + return isNarrowWidth ? children : null; +} + function StackedRoutesInner({ location }) { return ( @@ -147,12 +159,30 @@ function StackedRoutesInner({ location }) { } /> + } /> + } /> - } /> + + + + } + /> + + + + + } + /> ); } diff --git a/packages/desktop-client/src/components/accounts/MobileAccount.js b/packages/desktop-client/src/components/accounts/MobileAccount.js index 720596e54cc..4a11fd30d90 100644 --- a/packages/desktop-client/src/components/accounts/MobileAccount.js +++ b/packages/desktop-client/src/components/accounts/MobileAccount.js @@ -15,7 +15,6 @@ import * as queries from 'loot-core/src/client/queries'; import { pagedQuery } from 'loot-core/src/client/query-helpers'; import { send, listen } from 'loot-core/src/platform/client/fetch'; import { - getSplit, isPreviewId, ungroupTransactions, } from 'loot-core/src/shared/transactions'; @@ -185,7 +184,6 @@ export default function Account(props) { setSearchText(text); }; - // eslint-disable-next-line @typescript-eslint/no-unused-vars const onSelectTransaction = transaction => { if (isPreviewId(transaction.id)) { let parts = transaction.id.split('/'); @@ -214,17 +212,7 @@ export default function Account(props) { }, ); } else { - let trans = [transaction]; - if (transaction.parent_id || transaction.is_parent) { - let index = transactions.findIndex( - t => t.id === (transaction.parent_id || transaction.id), - ); - trans = getSplit(transactions, index); - } - - navigate('Transaction', { - transactions: trans, - }); + navigate(`transactions/${transaction.id}`); } }; @@ -269,7 +257,7 @@ export default function Account(props) { paged?.fetchNext(); }} onSearch={onSearch} - onSelectTransaction={() => {}} // onSelectTransaction} + onSelectTransaction={onSelectTransaction} /> ) } diff --git a/packages/desktop-client/src/components/accounts/MobileAccountDetails.js b/packages/desktop-client/src/components/accounts/MobileAccountDetails.js index 4c9ecb5b72d..88281b6f028 100644 --- a/packages/desktop-client/src/components/accounts/MobileAccountDetails.js +++ b/packages/desktop-client/src/components/accounts/MobileAccountDetails.js @@ -133,7 +133,7 @@ export default function AccountDetails({ TODO: connect to an add transaction modal Only left here but hidden for flex centering of the account name. */} - + + )} + + + + ); + } +} diff --git a/packages/desktop-client/src/components/mobile/MobileForms.js b/packages/desktop-client/src/components/mobile/MobileForms.js new file mode 100644 index 00000000000..67757db0436 --- /dev/null +++ b/packages/desktop-client/src/components/mobile/MobileForms.js @@ -0,0 +1,127 @@ +import { forwardRef } from 'react'; + +import { css } from 'glamor'; + +import { theme } from '../../style'; +import Button from '../common/Button'; +import Input from '../common/Input'; +import Text from '../common/Text'; +import View from '../common/View'; + +export const EDITING_PADDING = 12; +const FIELD_HEIGHT = 40; + +export function FieldLabel({ title, flush, style }) { + return ( + + {title} + + ); +} + +const valueStyle = { + borderWidth: 1, + borderColor: theme.formInputBorder, + marginLeft: -1, + marginRight: -1, + height: FIELD_HEIGHT, + paddingHorizontal: EDITING_PADDING, +}; + +export const InputField = forwardRef(function InputField( + { disabled, style, onUpdate, ...props }, + ref, +) { + return ( + { + onUpdate?.(e.target.value); + }} + style={[ + valueStyle, + style, + { + backgroundColor: disabled + ? theme.formInputTextReadOnlySelection + : 'white', + }, + ]} + {...props} + /> + ); +}); + +export function TapField({ + value, + children, + disabled, + rightContent, + style, + textStyle, + onClick, +}) { + return ( + + ); +} + +export function BooleanField({ checked, onUpdate, style }) { + return ( + onUpdate(e.target.checked)} + {...css([ + { + marginInline: EDITING_PADDING, + }, + style, + ])} + /> + ); +} diff --git a/packages/desktop-client/src/components/modals/EditField.js b/packages/desktop-client/src/components/modals/EditField.js index faff7a2612d..f8eb44e466d 100644 --- a/packages/desktop-client/src/components/modals/EditField.js +++ b/packages/desktop-client/src/components/modals/EditField.js @@ -7,6 +7,7 @@ import { currentDay, dayFromDate } from 'loot-core/src/shared/months'; import { amountToInteger } from 'loot-core/src/shared/util'; import { useActions } from '../../hooks/useActions'; +import { useResponsive } from '../../ResponsiveProvider'; import { colors } from '../../style'; import AccountAutocomplete from '../autocomplete/AccountAutocomplete'; import CategoryAutocomplete from '../autocomplete/CategorySelect'; @@ -39,11 +40,12 @@ export default function EditField({ modalProps, name, onSubmit }) { modalProps.onClose(); } + const { isNarrowWidth } = useResponsive(); let label, editor, minWidth; let inputStyle = { ':focus': { boxShadow: 0 } }; let autocompleteProps = { inputProps: { style: inputStyle }, - containerProps: { style: { height: 275 } }, + containerProps: { style: { height: isNarrowWidth ? '90vh' : 275 } }, }; switch (name) { @@ -74,11 +76,19 @@ export default function EditField({ modalProps, name, onSubmit }) { accounts={accounts} focused={true} embedded={true} + closeOnBlur={false} onSelect={value => { if (value) { onSelect(value); } }} + groupHeaderStyle={ + isNarrowWidth + ? { + color: colors.n6, + } + : undefined + } {...autocompleteProps} /> ); @@ -93,7 +103,9 @@ export default function EditField({ modalProps, name, onSubmit }) { value={null} focused={true} embedded={true} + closeOnBlur={false} showManagePayees={false} + showMakeTransfer={!isNarrowWidth} onSelect={async value => { if (value && value.startsWith('new:')) { value = await createPayee(value.slice('new:'.length)); @@ -102,6 +114,13 @@ export default function EditField({ modalProps, name, onSubmit }) { onSelect(value); }} isCreatable + groupHeaderStyle={ + isNarrowWidth + ? { + color: colors.n6, + } + : undefined + } {...autocompleteProps} /> ); @@ -126,11 +145,19 @@ export default function EditField({ modalProps, name, onSubmit }) { value={null} focused={true} embedded={true} + closeOnBlur={false} showSplitOption={false} onUpdate={() => {}} onSelect={value => { onSelect(value); }} + groupHeaderStyle={ + isNarrowWidth + ? { + color: colors.n6, + } + : undefined + } {...autocompleteProps} /> ); @@ -152,31 +179,35 @@ export default function EditField({ modalProps, name, onSubmit }) { return ( {() => ( - + {!isNarrowWidth && ( + + )} {editor} )} diff --git a/packages/desktop-client/src/components/transactions/MobileTransaction.js b/packages/desktop-client/src/components/transactions/MobileTransaction.js index bc7896786b7..d5d3b66b6c2 100644 --- a/packages/desktop-client/src/components/transactions/MobileTransaction.js +++ b/packages/desktop-client/src/components/transactions/MobileTransaction.js @@ -3,31 +3,66 @@ import React, { Component, forwardRef, useEffect, + useState, useRef, } from 'react'; +import { connect } from 'react-redux'; +import { useNavigate, useParams, Link } from 'react-router-dom'; import { useFocusRing } from '@react-aria/focus'; import { useListBox, useListBoxSection, useOption } from '@react-aria/listbox'; import { mergeProps } from '@react-aria/utils'; import { Item, Section } from '@react-stately/collections'; import { useListState } from '@react-stately/list'; +import { + format as formatDate, + parse as parseDate, + parseISO, + isValid as isValidDate, +} from 'date-fns'; import { css } from 'glamor'; import memoizeOne from 'memoize-one'; +import * as actions from 'loot-core/src/client/actions'; +import q, { runQuery } from 'loot-core/src/client/query-helpers'; +import { send } from 'loot-core/src/platform/client/fetch'; import * as monthUtils from 'loot-core/src/shared/months'; import { getScheduledAmount } from 'loot-core/src/shared/schedules'; +import { + ungroupTransactions, + updateTransaction, + realizeTempTransactions, +} from 'loot-core/src/shared/transactions'; import { titleFirst, integerToCurrency, + integerToAmount, + amountToInteger, + getChangedValues, + diffItems, groupById, } from 'loot-core/src/shared/util'; +import { useSetThemeColor } from '../../hooks/useSetThemeColor'; +import SvgAdd from '../../icons/v1/Add'; +import CheveronLeft from '../../icons/v1/CheveronLeft'; +import SvgTrash from '../../icons/v1/Trash'; import ArrowsSynchronize from '../../icons/v2/ArrowsSynchronize'; import CheckCircle1 from '../../icons/v2/CheckCircle1'; -import { styles, colors } from '../../style'; +import SvgPencilWriteAlternate from '../../icons/v2/PencilWriteAlternate'; +import { styles, colors, theme } from '../../style'; +import Button from '../common/Button'; import Text from '../common/Text'; import TextOneLine from '../common/TextOneLine'; import View from '../common/View'; +import { FocusableAmountInput } from '../mobile/MobileAmountInput'; +import { + FieldLabel, + TapField, + InputField, + BooleanField, + EDITING_PADDING, +} from '../mobile/MobileForms'; const zIndices = { SECTION_HEADING: 10 }; @@ -50,6 +85,50 @@ function getDescriptionPretty(transaction, payee, transferAcct) { return ''; } +function serializeTransaction(transaction, dateFormat) { + let { date, amount } = transaction; + return { + ...transaction, + date: formatDate(parseISO(date), dateFormat), + amount: integerToAmount(amount || 0), + }; +} + +function deserializeTransaction(transaction, originalTransaction, dateFormat) { + let { amount, date, ...realTransaction } = transaction; + + let dayMonth = monthUtils.getDayMonthRegex(dateFormat); + if (dayMonth.test(date)) { + let test = parseDate( + date, + monthUtils.getDayMonthFormat(dateFormat), + new Date(), + ); + if (isValidDate(test)) { + date = monthUtils.dayFromDate(test); + } else { + date = null; + } + } else { + let test = parseDate(date, dateFormat, new Date()); + // This is a quick sanity check to make sure something invalid + // like "year 201" was entered + if (test.getFullYear() > 2000 && isValidDate(test)) { + date = monthUtils.dayFromDate(test); + } else { + date = null; + } + } + + if (date == null) { + date = + (originalTransaction && originalTransaction.date) || + monthUtils.currentDay(); + } + + return { ...realTransaction, date, amount: amountToInteger(amount || 0) }; +} + function lookupName(items, id) { return items.find(item => item.id === id).name; } @@ -102,6 +181,679 @@ function Status({ status }) { ); } +const LEFT_RIGHT_FLEX_WIDTH = 70; +class TransactionEditInner extends PureComponent { + constructor(props) { + super(props); + this.state = { + transactions: props.transactions, + editingChild: null, + }; + } + + serializeTransactions = memoizeOne(transactions => { + return transactions.map(t => + serializeTransaction(t, this.props.dateFormat), + ); + }); + + componentDidMount() { + if (this.props.adding) { + this.amount.focus(); + } + } + + componentWillUnmount() { + document + .querySelector('meta[name="theme-color"]') + .setAttribute('content', '#ffffff'); + } + + openChildEdit = child => { + this.setState({ editingChild: child.id }); + }; + + onAdd = () => { + this.onSave(); + }; + + onSave = async () => { + let { transactions } = this.state; + const [transaction, ..._childTransactions] = transactions; + const { account: accountId } = transaction; + let account = getAccountsById(this.props.accounts)[accountId]; + + if (transactions.find(t => t.account == null)) { + // Ignore transactions if any of them don't have an account + return; + } + + // Since we don't own the state, we have to handle the case where + // the user saves while editing an input. We won't have the + // updated value so we "apply" a queued change. Maybe there's a + // better way to do this (lift the state?) + if (this._queuedChange) { + let [transaction, name, value] = this._queuedChange; + transactions = await this.onEdit(transaction, name, value); + } + + if (this.props.adding) { + transactions = realizeTempTransactions(transactions); + } + + this.props.onSave(transactions); + this.props.navigation(`/accounts/${account.id}`); + }; + + onSaveChild = childTransaction => { + this.setState({ editingChild: null }); + }; + + onEdit = async (transaction, name, value) => { + let { transactions } = this.state; + + let newTransaction = { ...transaction, [name]: value }; + if (this.props.onEdit) { + newTransaction = await this.props.onEdit(newTransaction); + } + + let { data: newTransactions } = updateTransaction( + transactions, + deserializeTransaction(newTransaction, null, this.props.dateFormat), + ); + + this._queuedChange = null; + this.setState({ transactions: newTransactions }); + return newTransactions; + }; + + onQueueChange = (transaction, name, value) => { + // This is an ugly hack to solve the problem that input's blur + // events are not fired when unmounting. If the user has focused + // an input and swipes back, it should still save, but because the + // blur event is not fired we need to manually track the latest + // change and apply it ourselves when unmounting + this._queuedChange = [transaction, name, value]; + }; + + onClick = (transactionId, name) => { + let { dateFormat } = this.props; + + this.props.pushModal('edit-field', { + name, + onSubmit: (name, value) => { + let { transactions } = this.state; + let transaction = transactions.find(t => t.id === transactionId); + // This is a deficiency of this API, need to fix. It + // assumes that it receives a serialized transaction, + // but we only have access to the raw transaction + this.onEdit(serializeTransaction(transaction, dateFormat), name, value); + }, + }); + }; + + render() { + const { + adding, + categories, + accounts, + payees, + renderChildEdit, + navigation, + onDelete, + } = this.props; + const { editingChild } = this.state; + const transactions = this.serializeTransactions( + this.state.transactions || [], + ); + const [transaction, ..._childTransactions] = transactions; + const { payee: payeeId, category, account: accountId } = transaction; + + // Child transactions should always default to the signage + // of the parent transaction + let forcedSign = transaction.amount < 0 ? 'negative' : 'positive'; + + let account = getAccountsById(accounts)[accountId]; + let payee = payees && payeeId && getPayeesById(payees)[payeeId]; + let transferAcct = + payee && + payee.transfer_acct && + getAccountsById(accounts)[payee.transfer_acct]; + + let descriptionPretty = getDescriptionPretty( + transaction, + payee, + transferAcct, + ); + + const transactionDate = parseDate( + transaction.date, + this.props.dateFormat, + new Date(), + ); + const dateDefaultValue = monthUtils.dayFromDate(transactionDate); + + return ( + // + + + + + + + Back + + + + {payeeId == null + ? adding + ? 'New Transaction' + : 'Transaction' + : descriptionPretty} + + {/* For centering the transaction title */} + + + + {/* (this.scrollView = el)} + automaticallyAdjustContentInsets={false} + keyboardShouldPersistTaps="always" + style={{ + backgroundColor: colors.n11, + flexGrow: 1, + overflow: 'hidden', + }} + contentContainerStyle={{ flexGrow: 1 }} + > */} + + + + (this.amount = el)} + value={transaction.amount} + zeroIsNegative={true} + onBlur={value => + this.onEdit(transaction, 'amount', value.toString()) + } + onChange={value => + this.onQueueChange(transaction, 'amount', value) + } + style={{ transform: [] }} + focusedStyle={{ + width: 'auto', + padding: '5px', + paddingLeft: '20px', + paddingRight: '20px', + minWidth: 120, + transform: [{ translateY: -0.5 }], + }} + textStyle={{ fontSize: 30, textAlign: 'center' }} + /> + + + + + this.onClick(transaction.id, 'payee')} + /> + + + + + {!transaction.is_parent ? ( + + // Split + // + // } + onClick={() => this.onClick(transaction.id, 'category')} + /> + ) : ( + + Split transaction editing is not supported on mobile at this + time. + + )} + + + + + this.onClick(transaction.id, 'account')} + /> + + + + + + + this.onEdit( + transaction, + 'date', + formatDate(parseISO(value), this.props.dateFormat), + ) + } + onChange={e => + this.onQueueChange( + transaction, + 'date', + formatDate( + parseISO(e.target.value), + this.props.dateFormat, + ), + ) + } + /> + + + + + + this.onEdit(transaction, 'cleared', checked) + } + style={{ marginTop: 4 }} + /> + + + + + + this.onEdit(transaction, 'notes', value)} + onChange={e => + this.onQueueChange(transaction, 'notes', e.target.value) + } + /> + + + {!adding && ( + + + + )} + + + + {adding ? ( + + ) : ( + + )} + + + {/* t.id === editingChild), + }} + > */} + {renderChildEdit({ + transaction: + editingChild && transactions.find(t => t.id === editingChild), + amountSign: forcedSign, + getCategoryName: id => (id ? lookupName(categories, id) : null), + navigation: navigation, + onEdit: this.onEdit, + onStartClose: this.onSaveChild, + })} + {/* */} + + + // + ); + } +} + +function isTemporary(transaction) { + return transaction.id.indexOf('temp') === 0; +} + +function makeTemporaryTransactions(currentAccountId, lastDate) { + return [ + { + id: 'temp', + date: lastDate || monthUtils.currentDay(), + account: currentAccountId, + amount: 0, + cleared: false, + }, + ]; +} + +function TransactionEditUnconnected(props) { + const { categories, accounts, payees, lastTransaction, dateFormat } = props; + let { id: accountId, transactionId } = useParams(); + let navigate = useNavigate(); + let [fetchedTransactions, setFetchedTransactions] = useState(null); + let transactions = []; + let adding = false; + let deleted = false; + + useSetThemeColor(colors.p5); + + useEffect(() => { + // May as well update categories / accounts when transaction ID changes + props.getCategories(); + props.getAccounts(); + props.getPayees(); + + async function fetchTransaction() { + let transactions = []; + if (transactionId) { + // Query for the transaction based on the ID with grouped splits. + // + // This means if the transaction in question is a split transaction, its + // subtransactions will be returned in the `substransactions` property on + // the parent transaction. + // + // The edit item components expect to work with a flat array of + // transactions when handling splits, so we call ungroupTransactions to + // flatten parent and children into one array. + let { data } = await runQuery( + q('transactions') + .filter({ id: transactionId }) + .select('*') + .options({ splits: 'grouped' }), + ); + transactions = ungroupTransactions(data); + setFetchedTransactions(transactions); + } + } + fetchTransaction(); + }, [transactionId]); + + if ( + categories.length === 0 || + accounts.length === 0 || + (transactionId && !fetchedTransactions) + ) { + return null; + } + + if (!transactionId) { + transactions = makeTemporaryTransactions( + accountId || (lastTransaction && lastTransaction.account) || null, + lastTransaction && lastTransaction.date, + ); + adding = true; + } else { + transactions = fetchedTransactions; + } + + const onEdit = async transaction => { + // Run the rules to auto-fill in any data. Right now we only do + // this on new transactions because that's how desktop works. + if (isTemporary(transaction)) { + let afterRules = await send('rules-run', { transaction }); + let diff = getChangedValues(transaction, afterRules); + + let newTransaction = { ...transaction }; + if (diff) { + Object.keys(diff).forEach(field => { + if (newTransaction[field] == null) { + newTransaction[field] = diff[field]; + } + }); + } + return newTransaction; + } + + return transaction; + }; + + const onSave = async newTransactions => { + if (deleted) { + return; + } + + const changes = diffItems(transactions || [], newTransactions); + if ( + changes.added.length > 0 || + changes.updated.length > 0 || + changes.deleted.length + ) { + const _remoteUpdates = await send('transactions-batch-update', { + added: changes.added, + deleted: changes.deleted, + updated: changes.updated, + }); + + // if (onTransactionsChange) { + // onTransactionsChange({ + // ...changes, + // updated: changes.updated.concat(remoteUpdates), + // }); + // } + } + + if (adding) { + // The first one is always the "parent" and the only one we care + // about + props.setLastTransaction(newTransactions[0]); + } + }; + + const onDelete = async () => { + // Eagerly go back + navigate(`/accounts/${accountId}`); + + if (adding) { + // Adding a new transactions, this disables saving when the component unmounts + deleted = true; + } else { + const changes = { deleted: transactions }; + const _remoteUpdates = await send('transactions-batch-update', changes); + // if (onTransactionsChange) { + // onTransactionsChange({ ...changes, updated: remoteUpdates }); + // } + } + }; + + return ( + + } + renderChildEdit={props => {}} + dateFormat={dateFormat} + // TODO: was this a mistake in the original code? + // onTapField={this.onTapField} + onEdit={onEdit} + onSave={onSave} + onDelete={onDelete} + /> + + ); +} + +export const TransactionEdit = connect( + state => ({ + categories: state.queries.categories.list, + payees: state.queries.payees, + lastTransaction: state.queries.lastTransaction, + accounts: state.queries.accounts, + dateFormat: state.prefs.local.dateFormat || 'MM/dd/yyyy', + }), + actions, +)(TransactionEditUnconnected); + class Transaction extends PureComponent { render() { const { @@ -111,7 +863,7 @@ class Transaction extends PureComponent { payees, showCategory, added, - // onSelect, + onSelect, style, } = this.props; let { @@ -155,94 +907,94 @@ class Transaction extends PureComponent { }; return ( - // + {isPreview ? ( + + ) : ( + + + {showCategory && ( + + {prettyCategory || 'Uncategorized'} + + )} + + )} + + + {integerToCurrency(amount)} + + + ); } } @@ -337,7 +1089,7 @@ export class TransactionList extends Component { payees={this.props.payees} showCategory={this.props.showCategory} added={this.props.isNew(transaction.id)} - onSelect={() => {}} // onSelect(transaction)} + onSelect={this.props.onSelect} // onSelect(transaction)} /> ); diff --git a/packages/loot-core/src/shared/transactions.ts b/packages/loot-core/src/shared/transactions.ts index ac5675b5a8e..967532554b4 100644 --- a/packages/loot-core/src/shared/transactions.ts +++ b/packages/loot-core/src/shared/transactions.ts @@ -70,7 +70,7 @@ function findParentIndex(transactions, idx) { return null; } -export function getSplit(transactions, parentIndex) { +function getSplit(transactions, parentIndex) { let split = [transactions[parentIndex]]; let curr = parentIndex + 1; while (curr < transactions.length && transactions[curr].is_child) { diff --git a/upcoming-release-notes/1340.md b/upcoming-release-notes/1340.md new file mode 100644 index 00000000000..1eed73f323a --- /dev/null +++ b/upcoming-release-notes/1340.md @@ -0,0 +1,6 @@ +--- +category: Features +authors: [Cldfire] +--- + +Add editing / adding transactions on mobile devices (via an initial port of the old React Native UI)