& {
categoryGroups: CategoryGroup[];
showSplitOption?: boolean;
+ groupHeaderStyle?: object;
};
export default function CategoryAutocomplete({
categoryGroups,
showSplitOption,
embedded,
+ closeOnBlur,
+ groupHeaderStyle,
...props
}: CategoryAutocompleteProps) {
let categorySuggestions = useMemo(
@@ -150,6 +199,7 @@ export default function CategoryAutocomplete({
strict={true}
highlightFirst={true}
embedded={embedded}
+ closeOnBlur={closeOnBlur}
getHighlightedIndex={suggestions => {
if (suggestions.length === 0) {
return null;
@@ -174,6 +224,7 @@ export default function CategoryAutocomplete({
embedded={embedded}
getItemProps={getItemProps}
highlightedIndex={highlightedIndex}
+ groupHeaderStyle={groupHeaderStyle}
/>
)}
{...props}
diff --git a/packages/desktop-client/src/components/autocomplete/PayeeAutocomplete.js b/packages/desktop-client/src/components/autocomplete/PayeeAutocomplete.js
index 11b73bb418a..147be825bef 100644
--- a/packages/desktop-client/src/components/autocomplete/PayeeAutocomplete.js
+++ b/packages/desktop-client/src/components/autocomplete/PayeeAutocomplete.js
@@ -1,12 +1,15 @@
import React, { Fragment, useState, useMemo } from 'react';
import { useDispatch } from 'react-redux';
+import { css } from 'glamor';
+
import { createPayee } from 'loot-core/src/client/actions/queries';
import { useCachedAccounts } from 'loot-core/src/client/data-hooks/accounts';
import { useCachedPayees } from 'loot-core/src/client/data-hooks/payees';
import { getActivePayees } from 'loot-core/src/client/reducers/queries';
import Add from '../../icons/v1/Add';
+import { useResponsive } from '../../ResponsiveProvider';
import { colors } from '../../style';
import Button from '../common/Button';
import View from '../common/View';
@@ -48,8 +51,14 @@ function PayeeList({
highlightedIndex,
embedded,
inputValue,
+ groupHeaderStyle,
footer,
}) {
+ const { isNarrowWidth } = useResponsive();
+ const highlightedIndexColor = isNarrowWidth
+ ? 'rgba(100, 100, 100, .15)'
+ : colors.n4;
+ const createNewColor = isNarrowWidth ? colors.g5 : colors.g8;
let isFiltered = items.filtered;
let createNew = null;
items = [...items];
@@ -81,16 +90,19 @@ function PayeeList({
flexShrink: 0,
padding: '6px 9px',
backgroundColor:
- highlightedIndex === 0 ? colors.n4 : 'transparent',
+ highlightedIndex === 0 ? highlightedIndexColor : 'transparent',
borderRadius: embedded ? 4 : 0,
+ ':active': {
+ backgroundColor: 'rgba(100, 100, 100, .25)',
+ },
}}
>
@@ -123,6 +135,7 @@ function PayeeList({
style={{
color: colors.y9,
padding: '4px 9px',
+ ...groupHeaderStyle,
}}
>
{title}
@@ -131,16 +144,43 @@ function PayeeList({
{item.name}
@@ -148,7 +188,7 @@ function PayeeList({
{showMoreMessage && (
{
if (!item) {
return '';
@@ -324,6 +367,7 @@ export default function PayeeAutocomplete({
highlightedIndex={highlightedIndex}
inputValue={inputValue}
embedded={embedded}
+ groupHeaderStyle={groupHeaderStyle}
footer={
{showMakeTransfer && (
diff --git a/packages/desktop-client/src/components/mobile/MobileAmountInput.js b/packages/desktop-client/src/components/mobile/MobileAmountInput.js
new file mode 100644
index 00000000000..b49065c9acc
--- /dev/null
+++ b/packages/desktop-client/src/components/mobile/MobileAmountInput.js
@@ -0,0 +1,315 @@
+import { PureComponent } from 'react';
+
+import {
+ toRelaxedNumber,
+ amountToCurrency,
+ getNumberFormat,
+} from 'loot-core/src/shared/util';
+
+import { theme } from '../../style';
+import Button from '../common/Button';
+import Text from '../common/Text';
+import View from '../common/View';
+
+function getValue(state) {
+ const { value } = state;
+ return value;
+}
+
+class AmountInput extends PureComponent {
+ static getDerivedStateFromProps(props, state) {
+ return { editing: state.text !== '' || state.editing };
+ }
+
+ constructor(props) {
+ super(props);
+ // this.backgroundValue = new Animated.Value(0);
+ this.backgroundValue = 0;
+
+ this.id = Math.random().toString().slice(0, 5);
+ this.state = {
+ editing: false,
+ text: '',
+ // These are actually set from the props when the field is
+ // focused
+ value: 0,
+ };
+ }
+
+ componentDidMount() {
+ if (this.props.focused) {
+ this.focus();
+ }
+ }
+
+ componentWillUnmount() {
+ if (this.removeListeners) {
+ this.removeListeners();
+ }
+ }
+
+ componentDidUpdate(prevProps, prevState) {
+ if (!prevProps.focused && this.props.focused) {
+ this.focus();
+ }
+
+ if (prevProps.value !== this.props.value) {
+ this.setState({
+ editing: false,
+ text: '',
+ ...this.getInitialValue(),
+ });
+ }
+ }
+
+ parseText() {
+ return toRelaxedNumber(
+ this.state.text.replace(/[,.]/, getNumberFormat().separator),
+ );
+ }
+
+ // animate() {
+ // this.animation = Animated.sequence([
+ // Animated.timing(this.backgroundValue, {
+ // toValue: 1,
+ // duration: 1200,
+ // useNativeDriver: true,
+ // }),
+ // Animated.timing(this.backgroundValue, {
+ // toValue: 0,
+ // duration: 1200,
+ // useNativeDriver: true,
+ // }),
+ // ]);
+
+ // this.animation.start(({ finished }) => {
+ // if (finished) {
+ // this.animate();
+ // }
+ // });
+ // }
+
+ onKeyPress = e => {
+ if (e.nativeEvent.key === 'Backspace' && this.state.text === '') {
+ this.setState({ editing: true });
+ }
+ };
+
+ getInitialValue() {
+ return {
+ value: Math.abs(this.props.value),
+ };
+ }
+
+ focus() {
+ this.input.focus();
+
+ const initialState = this.getInitialValue();
+ this.setState(initialState);
+ }
+
+ applyText = () => {
+ const { editing } = this.state;
+
+ const parsed = this.parseText();
+ const newValue = editing ? parsed : getValue(this.state);
+
+ this.setState({
+ value: Math.abs(newValue),
+ editing: false,
+ text: '',
+ });
+
+ return newValue;
+ };
+
+ onBlur = () => {
+ const value = this.applyText();
+ this.props.onBlur?.(value);
+ if (this.removeListeners) {
+ this.removeListeners();
+ }
+ };
+
+ onChangeText = text => {
+ let { onChange } = this.props;
+
+ this.setState({ text });
+ onChange(text);
+ };
+
+ render() {
+ const { style, textStyle } = this.props;
+ const { editing, value, text } = this.state;
+
+ let input = (
+ (this.input = el)}
+ value={text}
+ inputMode="decimal"
+ autoCapitalize="none"
+ onChange={e => this.onChangeText(e.target.value)}
+ onBlur={this.onBlur}
+ onKeyPress={this.onKeyPress}
+ data-testid="amount-input"
+ style={{ flex: 1, textAlign: 'center', position: 'absolute' }}
+ />
+ );
+
+ return (
+
+ {input}
+
+ {/* */}
+
+ {editing ? text : amountToCurrency(value)}
+
+
+ );
+ }
+}
+
+export class FocusableAmountInput extends PureComponent {
+ state = { focused: false, isNegative: true };
+
+ componentDidMount() {
+ if (this.props.sign) {
+ this.setState({ isNegative: this.props.sign === 'negative' });
+ } else if (
+ this.props.value > 0 ||
+ (!this.props.zeroIsNegative && this.props.value === 0)
+ ) {
+ this.setState({ isNegative: false });
+ }
+ }
+
+ focus = () => {
+ this.setState({ focused: true });
+ };
+
+ onFocus = () => {
+ this.focus();
+ };
+
+ toggleIsNegative = () => {
+ this.setState({ isNegative: !this.state.isNegative }, () => {
+ this.onBlur(this.props.value);
+ });
+ };
+
+ onBlur = value => {
+ this.setState({ focused: false, reallyFocused: false });
+ if (this.props.onBlur) {
+ const absValue = Math.abs(value);
+ this.props.onBlur(this.state.isNegative ? -absValue : absValue);
+ }
+ };
+
+ render() {
+ const { textStyle, style, focusedStyle, buttonProps } = this.props;
+ const { focused } = this.state;
+
+ return (
+
+ (this.amount = el)}
+ onBlur={this.onBlur}
+ focused={focused}
+ style={[
+ {
+ width: 80,
+ transform: [{ translateX: 6 }],
+ justifyContent: 'center',
+ },
+ style,
+ focusedStyle,
+ !focused && {
+ opacity: 0,
+ position: 'absolute',
+ top: 0,
+ },
+ ]}
+ textStyle={[{ fontSize: 15, textAlign: 'right' }, textStyle]}
+ />
+
+
+ {!focused && (
+
+ )}
+
+
+
+ );
+ }
+}
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)