diff --git a/packages/desktop-client/e2e/mobile.test.js b/packages/desktop-client/e2e/mobile.test.js index fa53ecd515283c71ec8ecc43d562266f04186487..b67a03e42437f9d62c01efb6a2767f531666bab5 100644 --- a/packages/desktop-client/e2e/mobile.test.js +++ b/packages/desktop-client/e2e/mobile.test.js @@ -83,6 +83,8 @@ test.describe('Mobile', () => { await expect(transactionEntryPage.header).toHaveText('New Transaction'); await transactionEntryPage.amountField.fill('12.34'); + // Click anywhere to cancel active edit. + await transactionEntryPage.header.click(); await transactionEntryPage.fillField( page.getByTestId('payee-field'), 'Kroger', @@ -114,6 +116,8 @@ test.describe('Mobile', () => { await expect(page).toMatchThemeScreenshots(); await transactionEntryPage.amountField.fill('12.34'); + // Click anywhere to cancel active edit. + await transactionEntryPage.header.click(); await transactionEntryPage.fillField( page.getByTestId('payee-field'), 'Kroger', diff --git a/packages/desktop-client/e2e/mobile.test.js-snapshots/Mobile-creates-a-transaction-from-accounts-id-page-1-chromium-linux.png b/packages/desktop-client/e2e/mobile.test.js-snapshots/Mobile-creates-a-transaction-from-accounts-id-page-1-chromium-linux.png index 109397af4d87b9d517562e55791d9301490c205d..71c34be4250eb6f6bab7ca82e2b43ef17915e459 100644 Binary files a/packages/desktop-client/e2e/mobile.test.js-snapshots/Mobile-creates-a-transaction-from-accounts-id-page-1-chromium-linux.png and b/packages/desktop-client/e2e/mobile.test.js-snapshots/Mobile-creates-a-transaction-from-accounts-id-page-1-chromium-linux.png differ diff --git a/packages/desktop-client/e2e/mobile.test.js-snapshots/Mobile-creates-a-transaction-from-accounts-id-page-2-chromium-linux.png b/packages/desktop-client/e2e/mobile.test.js-snapshots/Mobile-creates-a-transaction-from-accounts-id-page-2-chromium-linux.png index d0990083a9997d3b05cb0a02ed22ace0d28f1d7e..885ad0401f9cb1d33aaf9feb42ff44da1f8b09c2 100644 Binary files a/packages/desktop-client/e2e/mobile.test.js-snapshots/Mobile-creates-a-transaction-from-accounts-id-page-2-chromium-linux.png and b/packages/desktop-client/e2e/mobile.test.js-snapshots/Mobile-creates-a-transaction-from-accounts-id-page-2-chromium-linux.png differ diff --git a/packages/desktop-client/e2e/mobile.test.js-snapshots/Mobile-creates-a-transaction-via-footer-button-1-chromium-linux.png b/packages/desktop-client/e2e/mobile.test.js-snapshots/Mobile-creates-a-transaction-via-footer-button-1-chromium-linux.png index 98d8921bfd4263ab933679ad3067d6ae7645b2ac..f66759c3186679ff08022e4ff6f7a8fce7bdd71a 100644 Binary files a/packages/desktop-client/e2e/mobile.test.js-snapshots/Mobile-creates-a-transaction-via-footer-button-1-chromium-linux.png and b/packages/desktop-client/e2e/mobile.test.js-snapshots/Mobile-creates-a-transaction-via-footer-button-1-chromium-linux.png differ diff --git a/packages/desktop-client/e2e/mobile.test.js-snapshots/Mobile-creates-a-transaction-via-footer-button-2-chromium-linux.png b/packages/desktop-client/e2e/mobile.test.js-snapshots/Mobile-creates-a-transaction-via-footer-button-2-chromium-linux.png index ff9a2444f99745d1cfdb67660be71dd7bd4e1b38..3f118cdc0acbe3b5f4fe4a92adc82f0bbfbc8133 100644 Binary files a/packages/desktop-client/e2e/mobile.test.js-snapshots/Mobile-creates-a-transaction-via-footer-button-2-chromium-linux.png and b/packages/desktop-client/e2e/mobile.test.js-snapshots/Mobile-creates-a-transaction-via-footer-button-2-chromium-linux.png differ diff --git a/packages/desktop-client/e2e/mobile.test.js-snapshots/Mobile-creates-a-transaction-via-footer-button-3-chromium-linux.png b/packages/desktop-client/e2e/mobile.test.js-snapshots/Mobile-creates-a-transaction-via-footer-button-3-chromium-linux.png index 27df8c3e591cd3da4e725d0a204947c4a16edf8a..3404a4f5b328384777d44604afab43d4522e7e08 100644 Binary files a/packages/desktop-client/e2e/mobile.test.js-snapshots/Mobile-creates-a-transaction-via-footer-button-3-chromium-linux.png and b/packages/desktop-client/e2e/mobile.test.js-snapshots/Mobile-creates-a-transaction-via-footer-button-3-chromium-linux.png differ diff --git a/packages/desktop-client/e2e/mobile.test.js-snapshots/Mobile-creates-a-transaction-via-footer-button-4-chromium-linux.png b/packages/desktop-client/e2e/mobile.test.js-snapshots/Mobile-creates-a-transaction-via-footer-button-4-chromium-linux.png index ff627b5a0a609de8a653f4b376ae45c30e089295..6bb639fa3de5981100833af0036f80ddf9633c6f 100644 Binary files a/packages/desktop-client/e2e/mobile.test.js-snapshots/Mobile-creates-a-transaction-via-footer-button-4-chromium-linux.png and b/packages/desktop-client/e2e/mobile.test.js-snapshots/Mobile-creates-a-transaction-via-footer-button-4-chromium-linux.png differ diff --git a/packages/desktop-client/src/components/Modals.tsx b/packages/desktop-client/src/components/Modals.tsx index 6c9238faf2a6e537ef4daad53640afc5d7223653..8f4fadba8363db7cf2a811cab79c938aa14386a9 100644 --- a/packages/desktop-client/src/components/Modals.tsx +++ b/packages/desktop-client/src/components/Modals.tsx @@ -232,6 +232,7 @@ export default function Modals() { modalProps={modalProps} name={options.name} onSubmit={options.onSubmit} + onClose={options.onClose} /> ); diff --git a/packages/desktop-client/src/components/budget/MobileBudgetTable.jsx b/packages/desktop-client/src/components/budget/MobileBudgetTable.jsx index 4f8e81107c107377589bf384108759ae42632065..eb7905bd8dfc5179fc30ddf194c83c7de17b8233 100644 --- a/packages/desktop-client/src/components/budget/MobileBudgetTable.jsx +++ b/packages/desktop-client/src/components/budget/MobileBudgetTable.jsx @@ -151,7 +151,7 @@ function BudgetCell({ return ( <View style={style}> <AmountInput - initialValue={sheetValue} + value={sheetValue} zeroSign="+" style={{ ...(!isEditing && { display: 'none' }), @@ -160,7 +160,7 @@ function BudgetCell({ }} focused={isEditing} textStyle={{ ...styles.smallText, ...textStyle }} - onChange={updateBudgetAmount} + onUpdate={updateBudgetAmount} onBlur={() => onEdit?.(null)} /> <View diff --git a/packages/desktop-client/src/components/mobile/MobileAmountInput.jsx b/packages/desktop-client/src/components/mobile/MobileAmountInput.jsx index 4155bffab6afd235df165f144c9e4af1b20e680f..207912155ff9ca92d9d6231c8fcfe30439d71e5f 100644 --- a/packages/desktop-client/src/components/mobile/MobileAmountInput.jsx +++ b/packages/desktop-client/src/components/mobile/MobileAmountInput.jsx @@ -1,4 +1,4 @@ -import { PureComponent } from 'react'; +import { memo, useEffect, useRef, useState } from 'react'; import { toRelaxedNumber, @@ -11,302 +11,218 @@ 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(); +const AmountInput = memo(function AmountInput({ + focused, + style, + textStyle, + ...props +}) { + const [editing, setEditing] = useState(false); + const [text, setText] = useState(''); + const [value, setValue] = useState(0); + const inputRef = useRef(); + + const getInitialValue = () => Math.abs(props.value); + + useEffect(() => { + if (focused) { + focus(); } - } + }, []); - componentWillUnmount() { - if (this.removeListeners) { - this.removeListeners(); - } - } - - componentDidUpdate(prevProps, prevState) { - if (!prevProps.focused && this.props.focused) { - this.focus(); - } + useEffect(() => { + setEditing(text !== ''); + }, [text]); - if (prevProps.value !== this.props.value) { - this.setState({ - editing: false, - text: '', - ...this.getInitialValue(), - }); + useEffect(() => { + if (focused) { + focus(); } - } + }, [focused]); - parseText() { - return toRelaxedNumber( - this.state.text.replace(/[,.]/, getNumberFormat().separator), - ); - } + useEffect(() => { + setEditing(false); + setText(''); + setValue(getInitialValue()); + }, [props.value]); - // 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(); - // } - // }); - // } + const parseText = () => { + return toRelaxedNumber(text.replace(/[,.]/, getNumberFormat().separator)); + }; - onKeyPress = e => { - if (e.nativeEvent.key === 'Backspace' && this.state.text === '') { - this.setState({ editing: true }); + const onKeyPress = e => { + if (e.key === 'Backspace' && text === '') { + setEditing(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 focus = () => { + inputRef.current?.focus(); + setValue(getInitialValue()); + }; - const parsed = this.parseText(); - const newValue = editing ? parsed : getValue(this.state); + const applyText = () => { + const parsed = parseText(); + const newValue = editing ? parsed : value; - this.setState({ - value: Math.abs(newValue), - editing: false, - text: '', - }); + setValue(Math.abs(newValue)); + setEditing(false); + setText(''); return newValue; }; - onBlur = () => { - const value = this.applyText(); - this.props.onBlur?.(value); - if (this.removeListeners) { - this.removeListeners(); - } + const onBlur = () => { + const value = applyText(); + props.onUpdate?.(value); }; - onChangeText = text => { - const { onChange } = this.props; - - this.setState({ text }); - onChange(text); + const onChangeText = text => { + setText(text); + props.onChange?.(text); }; - render() { - const { style, textStyle } = this.props; - const { editing, value, text } = this.state; - - const input = ( - <input - type="text" - ref={el => (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 ( - <View - style={{ - justifyContent: 'center', - borderWidth: 1, - borderColor: theme.pillBorderSelected, - borderRadius: 4, - padding: 5, - backgroundColor: theme.tableBackground, - ...style, - }} + const input = ( + <input + type="text" + ref={inputRef} + value={text} + inputMode="decimal" + autoCapitalize="none" + onChange={e => onChangeText(e.target.value)} + onBlur={onBlur} + onKeyUp={onKeyPress} + data-testid="amount-input" + style={{ flex: 1, textAlign: 'center', position: 'absolute' }} + /> + ); + + return ( + <View + style={{ + justifyContent: 'center', + borderWidth: 1, + borderColor: theme.pillBorderSelected, + borderRadius: 4, + padding: 5, + backgroundColor: theme.tableBackground, + ...style, + }} + > + <View style={{ overflowY: 'auto', overflowX: 'hidden' }}>{input}</View> + <Text + style={textStyle} + data-testid="amount-fake-input" + pointerEvents="none" > - <View style={{ overflowY: 'auto', overflowX: 'hidden' }}>{input}</View> - - {/* <Animated.View - style={{ - position: 'absolute', - left: 0, - right: 0, - bottom: 0, - top: 0, - - backgroundColor: animationColor || colors.p10, - opacity: this.backgroundValue, - borderRadius: 2, - }} - pointerEvents="none" - /> */} - <Text - style={textStyle} - data-testid="amount-fake-input" - pointerEvents="none" - > - {editing ? text : amountToCurrency(value)} - </Text> - </View> - ); - } -} - -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 }); + {editing ? text : amountToCurrency(value)} + </Text> + </View> + ); +}); + +export const FocusableAmountInput = memo(function FocusableAmountInput({ + value, + sign, // + or - + zeroSign, // + or - + focused, + textStyle, + style, + focusedStyle, + buttonProps, + onFocus, + ...props +}) { + const [isNegative, setIsNegative] = useState(true); + + useEffect(() => { + if (sign) { + setIsNegative(sign === '-'); + } else if (value > 0 || (zeroSign !== '-' && value === 0)) { + setIsNegative(false); } - } - - focus = () => { - this.setState({ focused: true }); - }; + }, []); - onFocus = () => { - this.focus(); + const toggleIsNegative = () => { + setIsNegative(!isNegative); + props.onUpdate?.(maybeApplyNegative(value, !isNegative)); }; - toggleIsNegative = () => { - this.setState({ isNegative: !this.state.isNegative }, () => { - this.onBlur(this.props.value); - }); + const maybeApplyNegative = (val, negative) => { + const absValue = Math.abs(val); + return negative ? -absValue : absValue; }; - maybeApplyNegative = value => { - const absValue = Math.abs(value); - return this.state.isNegative ? -absValue : absValue; + const onUpdate = val => { + props.onUpdate?.(maybeApplyNegative(val, isNegative)); }; - onBlur = value => { - this.setState({ focused: false, reallyFocused: false }); - this.props.onBlur?.(this.maybeApplyNegative(value)); + const onChange = val => { + props.onChange?.(maybeApplyNegative(val, isNegative)); }; - onChange = value => this.props.onChange?.(this.maybeApplyNegative(value)); - - render() { - const { textStyle, style, focusedStyle, buttonProps } = this.props; - const { focused } = this.state; + return ( + <View> + <AmountInput + {...props} + value={value} + onChange={onChange} + onUpdate={onUpdate} + focused={focused} + style={{ + width: 80, + justifyContent: 'center', + ...style, + ...focusedStyle, + ...(!focused && { + display: 'none', + }), + }} + textStyle={{ fontSize: 15, textAlign: 'right', ...textStyle }} + /> - return ( <View> - <AmountInput - {...this.props} - ref={el => (this.amount = el)} - onChange={this.onChange} - 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 }} - /> - - <View> - {!focused && ( - <Button - style={{ - position: 'absolute', - right: 'calc(100% + 5px)', - top: '8px', - }} - onClick={this.toggleIsNegative} - > - {this.state.isNegative ? '−' : '+'} - </Button> - )} + {!focused && ( <Button - onClick={this.onFocus} - // Defines how far touch can start away from the button - // hitSlop={{ top: 5, bottom: 5, left: 5, right: 5 }} - {...buttonProps} style={{ - ...(buttonProps && buttonProps.style), - ...(focused && { display: 'none' }), - ':hover': { - backgroundColor: 'transparent', - }, + position: 'absolute', + right: 'calc(100% + 5px)', + top: '8px', }} - type="bare" + onClick={toggleIsNegative} > - <View - style={{ - borderBottomWidth: 1, - borderColor: '#e0e0e0', - justifyContent: 'center', - transform: [{ translateY: 0.5 }], - ...style, - }} - > - <Text style={{ fontSize: 15, userSelect: 'none', ...textStyle }}> - {amountToCurrency(Math.abs(this.props.value))} - </Text> - </View> + {isNegative ? '-' : '+'} </Button> - </View> + )} + <Button + onClick={onFocus} + // Defines how far touch can start away from the button + // hitSlop={{ top: 5, bottom: 5, left: 5, right: 5 }} + {...buttonProps} + style={{ + ...(buttonProps && buttonProps.style), + ...(focused && { display: 'none' }), + ':hover': { + backgroundColor: 'transparent', + }, + }} + type="bare" + > + <View + style={{ + borderBottomWidth: 1, + borderColor: '#e0e0e0', + justifyContent: 'center', + transform: [{ translateY: 0.5 }], + ...style, + }} + > + <Text style={{ fontSize: 15, userSelect: 'none', ...textStyle }}> + {amountToCurrency(Math.abs(value))} + </Text> + </View> + </Button> </View> - ); - } -} + </View> + ); +}); diff --git a/packages/desktop-client/src/components/mobile/MobileForms.jsx b/packages/desktop-client/src/components/mobile/MobileForms.jsx index c7f084574ecdd84bfb66b0360584faee1df69416..4a7334fbd8416c0d65eaefc68a19ea1d1412ecd3 100644 --- a/packages/desktop-client/src/components/mobile/MobileForms.jsx +++ b/packages/desktop-client/src/components/mobile/MobileForms.jsx @@ -18,7 +18,7 @@ export function FieldLabel({ title, flush, style }) { marginTop: flush ? 0 : 25, fontSize: 13, color: theme.tableRowHeaderText, - paddingLeft: styles.mobileEditingPadding, + padding: `0 ${styles.mobileEditingPadding}px`, textTransform: 'uppercase', userSelect: 'none', ...style, @@ -35,7 +35,6 @@ const valueStyle = { marginLeft: 8, marginRight: 8, height: FIELD_HEIGHT, - paddingHorizontal: styles.mobileEditingPadding, }; export const InputField = forwardRef(function InputField( @@ -44,7 +43,7 @@ export const InputField = forwardRef(function InputField( ) { return ( <Input - ref={ref} + inputRef={ref} autoCorrect="false" autoCapitalize="none" disabled={disabled} diff --git a/packages/desktop-client/src/components/modals/EditField.jsx b/packages/desktop-client/src/components/modals/EditField.jsx index 853c85534be0d036401939dd6dc36c63ba7c8b5b..abd07d79feafaf97a9cc30039792fc2b8ec2e9c7 100644 --- a/packages/desktop-client/src/components/modals/EditField.jsx +++ b/packages/desktop-client/src/components/modals/EditField.jsx @@ -34,7 +34,7 @@ function CreatePayeeIcon(props) { return <Add {...props} width={14} height={14} />; } -export default function EditField({ modalProps, name, onSubmit }) { +export default function EditField({ modalProps, name, onSubmit, onClose }) { const dateFormat = useSelector( state => state.prefs.local.dateFormat || 'MM/dd/yyyy', ); @@ -44,6 +44,11 @@ export default function EditField({ modalProps, name, onSubmit }) { const { createPayee } = useActions(); + const onCloseInner = () => { + modalProps.onClose(); + onClose?.(); + }; + function onSelect(value) { if (value != null) { // Process the value if needed @@ -53,7 +58,7 @@ export default function EditField({ modalProps, name, onSubmit }) { onSubmit(name, value); } - modalProps.onClose(); + onCloseInner(); } const itemStyle = { @@ -268,6 +273,7 @@ export default function EditField({ modalProps, name, onSubmit }) { showHeader={isNarrowWidth} focusAfterClose={false} {...modalProps} + onClose={onCloseInner} padding={0} style={{ flex: 0, diff --git a/packages/desktop-client/src/components/schedules/EditSchedule.jsx b/packages/desktop-client/src/components/schedules/EditSchedule.jsx index adf731ec447ccc9af94cb5795fdcbe4bde55ee14..5beaa5968c86bd3a5e4fc1835f31cacb753f2ae0 100644 --- a/packages/desktop-client/src/components/schedules/EditSchedule.jsx +++ b/packages/desktop-client/src/components/schedules/EditSchedule.jsx @@ -514,8 +514,8 @@ export default function ScheduleDetails({ modalProps, actions, id }) { ) : ( <AmountInput id="amount-field" - initialValue={state.fields.amount} - onChange={value => + value={state.fields.amount} + onUpdate={value => dispatch({ type: 'set-field', field: 'amount', diff --git a/packages/desktop-client/src/components/transactions/MobileTransaction.jsx b/packages/desktop-client/src/components/transactions/MobileTransaction.jsx index 369ea54da8fab8f866b13ddcbb9e349a60b119d4..7763f1bf639c2ef0097efdd7d56f73d97922df1f 100644 --- a/packages/desktop-client/src/components/transactions/MobileTransaction.jsx +++ b/packages/desktop-client/src/components/transactions/MobileTransaction.jsx @@ -1,10 +1,10 @@ import React, { - PureComponent, - Component, forwardRef, useEffect, useState, useRef, + memo, + useMemo, } from 'react'; import { useSelector } from 'react-redux'; import { useParams } from 'react-router-dom'; @@ -21,7 +21,6 @@ import { isValid as isValidDate, } from 'date-fns'; import { css } from 'glamor'; -import memoizeOne from 'memoize-one'; import q, { runQuery } from 'loot-core/src/client/query-helpers'; import { send } from 'loot-core/src/platform/client/fetch'; @@ -32,6 +31,9 @@ import { ungroupTransactions, updateTransaction, realizeTempTransactions, + splitTransaction, + addSplitTransaction, + deleteTransaction, } from 'loot-core/src/shared/transactions'; import { titleFirst, @@ -47,12 +49,17 @@ import { useActions } from '../../hooks/useActions'; import useCategories from '../../hooks/useCategories'; import useNavigate from '../../hooks/useNavigate'; import { useSetThemeColor } from '../../hooks/useSetThemeColor'; -import SvgAdd from '../../icons/v1/Add'; -import SvgTrash from '../../icons/v1/Trash'; +import { + SingleActiveEditFormProvider, + useSingleActiveEditForm, +} from '../../hooks/useSingleActiveEditForm'; +import Split from '../../icons/v0/Split'; +import Add from '../../icons/v1/Add'; +import Trash from '../../icons/v1/Trash'; import ArrowsSynchronize from '../../icons/v2/ArrowsSynchronize'; import CheckCircle1 from '../../icons/v2/CheckCircle1'; import Lock from '../../icons/v2/LockClosed'; -import SvgPencilWriteAlternate from '../../icons/v2/PencilWriteAlternate'; +import PencilWriteAlternate from '../../icons/v2/PencilWriteAlternate'; import { styles, theme } from '../../style'; import Button from '../common/Button'; import Text from '../common/Text'; @@ -67,11 +74,13 @@ import { } from '../mobile/MobileForms'; import MobileBackButton from '../MobileBackButton'; import { Page } from '../Page'; +import { AmountInput } from '../util/AmountInput'; const zIndices = { SECTION_HEADING: 10 }; -const getPayeesById = memoizeOne(payees => groupById(payees)); -const getAccountsById = memoizeOne(accounts => groupById(accounts)); +function getFieldName(transactionId, field) { + return `${field}-${transactionId}`; +} function getDescriptionPretty(transaction, payee, transferAcct) { const { amount } = transaction; @@ -167,80 +176,352 @@ function Status({ status }) { ); } -class TransactionEditInner extends PureComponent { - constructor(props) { - super(props); - this.state = { - transactions: props.transactions, - editingChild: null, - }; - } +function Footer({ + transactions, + adding, + onAdd, + onSave, + onSplit, + onAddSplit, + onEmptySplitFound, +}) { + const [transaction, ...childTransactions] = transactions; + const onClickRemainingSplit = () => { + if (childTransactions.length === 0) { + onSplit(transaction.id); + } else { + const emptySplitTransaction = childTransactions.find(t => t.amount === 0); + if (!emptySplitTransaction) { + onAddSplit(transaction.id); + } else { + onEmptySplitFound?.(emptySplitTransaction.id); + } + } + }; + + return ( + <View + style={{ + paddingLeft: styles.mobileEditingPadding, + paddingRight: styles.mobileEditingPadding, + paddingTop: 10, + paddingBottom: 10, + backgroundColor: theme.tableHeaderBackground, + borderTopWidth: 1, + borderColor: theme.tableBorder, + }} + > + {transaction.error?.type === 'SplitTransactionError' ? ( + <Button + type="primary" + style={{ height: 40 }} + onClick={onClickRemainingSplit} + onPointerDown={e => e.preventDefault()} + > + <Split width={17} height={17} /> + <Text + style={{ + ...styles.text, + marginLeft: 6, + }} + > + Amount left:{' '} + {integerToCurrency( + transaction.amount > 0 + ? transaction.error.difference + : -transaction.error.difference, + )} + </Text> + </Button> + ) : adding ? ( + <Button + style={{ height: 40 }} + onClick={onAdd} + onPointerDown={e => e.preventDefault()} + > + <Add width={17} height={17} style={{ color: theme.formLabelText }} /> + <Text + style={{ + ...styles.text, + color: theme.formLabelText, + marginLeft: 5, + }} + > + Add transaction + </Text> + </Button> + ) : ( + <Button + style={{ height: 40 }} + onClick={onSave} + onPointerDown={e => e.preventDefault()} + > + <PencilWriteAlternate + width={16} + height={16} + style={{ + color: theme.formLabelText, + }} + /> + <Text + style={{ + ...styles.text, + marginLeft: 6, + color: theme.formLabelText, + }} + > + Save changes + </Text> + </Button> + )} + </View> + ); +} - serializeTransactions = memoizeOne(transactions => { - return transactions.map(t => - serializeTransaction(t, this.props.dateFormat), +const ChildTransactionEdit = forwardRef( + ( + { + transaction, + amountSign, + getCategory, + getPrettyPayee, + isOffBudget, + isBudgetTransfer, + onClick, + onEdit, + onDelete, + }, + ref, + ) => { + const { editingField, onRequestActiveEdit, onClearActiveEdit } = + useSingleActiveEditForm(); + return ( + <View + innerRef={ref} + style={{ + backgroundColor: theme.tableBackground, + borderColor: + transaction.amount === 0 + ? theme.tableBorderSelected + : theme.tableBorder, + borderWidth: '1px', + borderRadius: '5px', + padding: '5px', + margin: '10px', + }} + > + <View style={{ flexDirection: 'row' }}> + <View style={{ flexBasis: '75%' }}> + <FieldLabel title="Payee" /> + <TapField + disabled={ + editingField && + editingField !== getFieldName(transaction.id, 'payee') + } + value={getPrettyPayee(transaction)} + onClick={() => onClick(transaction.id, 'payee')} + data-testid={`payee-field-${transaction.id}`} + /> + </View> + <View + style={{ + flexBasis: '25%', + }} + > + <FieldLabel title="Amount" style={{ padding: 0 }} /> + <AmountInput + disabled={ + editingField && + editingField !== getFieldName(transaction.id, 'amount') + } + focused={transaction.amount === 0} + value={amountToInteger(transaction.amount)} + zeroSign={amountSign} + style={{ marginRight: 8 }} + textStyle={{ ...styles.smallText, textAlign: 'right' }} + onFocus={() => + onRequestActiveEdit(getFieldName(transaction.id, 'amount')) + } + onUpdate={value => { + const amount = integerToAmount(value); + if (transaction.amount !== amount) { + onEdit(transaction, 'amount', amount); + } else { + onClearActiveEdit(); + } + }} + /> + </View> + </View> + + <View> + <FieldLabel title="Category" /> + <TapField + style={{ + ...((isOffBudget || isBudgetTransfer(transaction)) && { + fontStyle: 'italic', + color: theme.pageTextSubdued, + fontWeight: 300, + }), + }} + value={getCategory(transaction, isOffBudget)} + disabled={ + (editingField && + editingField !== getFieldName(transaction.id, 'category')) || + isOffBudget || + isBudgetTransfer(transaction) + } + onClick={() => onClick(transaction.id, 'category')} + data-testid={`category-field-${transaction.id}`} + /> + </View> + + <View> + <FieldLabel title="Notes" /> + <InputField + disabled={ + editingField && + editingField !== getFieldName(transaction.id, 'notes') + } + defaultValue={transaction.notes} + onFocus={() => + onRequestActiveEdit(getFieldName(transaction.id, 'notes')) + } + onUpdate={value => onEdit(transaction, 'notes', value)} + /> + </View> + + <View style={{ alignItems: 'center' }}> + <Button + onClick={() => onDelete(transaction.id)} + onPointerDown={e => e.preventDefault()} + style={{ + height: 40, + borderWidth: 0, + marginLeft: styles.mobileEditingPadding, + marginRight: styles.mobileEditingPadding, + marginTop: 10, + backgroundColor: 'transparent', + }} + type="bare" + > + <Trash width={17} height={17} style={{ color: theme.errorText }} /> + <Text + style={{ + color: theme.errorText, + marginLeft: 5, + userSelect: 'none', + }} + > + Delete split + </Text> + </Button> + </View> + </View> ); - }); + }, +); + +const TransactionEditInner = memo(function TransactionEditInner({ + adding, + accounts, + categories, + payees, + dateFormat, + transactions: unserializedTransactions, + navigate, + pushModal, + ...props +}) { + const { editingField, onRequestActiveEdit, onClearActiveEdit } = + useSingleActiveEditForm(); + const [totalAmountFocused, setTotalAmountFocused] = useState(false); + const childTransactionElementRefMap = useRef({}); + + const payeesById = useMemo(() => groupById(payees), [payees]); + const accountsById = useMemo(() => groupById(accounts), [accounts]); + + const getAccount = trans => { + return trans?.account && accountsById?.[trans.account]; + }; - componentDidMount() { - if (this.props.adding) { - this.amount.focus(); - } - } + const getPayee = trans => { + return trans?.payee && payeesById?.[trans.payee]; + }; - componentWillUnmount() { - document - .querySelector('meta[name="theme-color"]') - .setAttribute('content', '#ffffff'); - } + const getTransferAcct = trans => { + const payee = trans && getPayee(trans); + return payee?.transfer_acct && accountsById?.[payee.transfer_acct]; + }; + + const getPrettyPayee = trans => { + const transPayee = trans && getPayee(trans); + const transTransferAcct = trans && getTransferAcct(trans); + return getDescriptionPretty(trans, transPayee, transTransferAcct); + }; + + const isBudgetTransfer = trans => { + const transferAcct = trans && getTransferAcct(trans); + return transferAcct && !transferAcct.offbudget; + }; + + const getCategory = (trans, isOffBudget) => { + return isOffBudget + ? 'Off Budget' + : isBudgetTransfer(trans) + ? 'Transfer' + : lookupName(categories, trans.category); + }; - openChildEdit = child => { - this.setState({ editingChild: child.id }); + const onTotalAmountEdit = () => { + onRequestActiveEdit?.(getFieldName(transaction.id, 'amount'), () => { + setTotalAmountFocused(true); + return () => setTotalAmountFocused(false); + }); }; - onAdd = () => { - this.onSave(); + useEffect(() => { + if (adding) { + onTotalAmountEdit(); + } + }, []); + + const onTotalAmountUpdate = value => { + if (transaction.amount !== value) { + onEdit(transaction, 'amount', value.toString()); + } else { + onClearActiveEdit(); + } }; - onSave = async () => { + const onSave = async () => { + const [transaction] = unserializedTransactions; + const onConfirmSave = async () => { - let { transactions } = this.state; - const [transaction, ..._childTransactions] = transactions; const { account: accountId } = transaction; - const account = getAccountsById(this.props.accounts)[accountId]; + const account = accountsById[accountId]; - if (transactions.find(t => t.account == null)) { + if (unserializedTransactions.find(t => t.account == null)) { // Ignore transactions if any of them don't have an account // TODO: Should we display validation error? 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) { - const [transaction, name, value] = this._queuedChange; - transactions = await this.onEdit(transaction, name, value); + let transactionsToSave = unserializedTransactions; + if (adding) { + transactionsToSave = realizeTempTransactions(unserializedTransactions); } - if (this.props.adding) { - transactions = realizeTempTransactions(transactions); - } - - this.props.onSave(transactions); - this.props.navigate(`/accounts/${account.id}`, { replace: true }); + props.onSave(transactionsToSave); + navigate(`/accounts/${account.id}`, { replace: true }); }; - const { transactions } = this.state; - const [transaction] = transactions; - if (transaction.reconciled) { // On mobile any save gives the warning. // On the web only certain changes trigger a warning. // Should we bring that here as well? Or does the nature of the editing form // make this more appropriate? - this.props.pushModal('confirm-transaction-edit', { + pushModal('confirm-transaction-edit', { onConfirm: onConfirmSave, confirmReason: 'editReconciled', }); @@ -249,72 +530,58 @@ class TransactionEditInner extends PureComponent { } }; - onSaveChild = childTransaction => { - this.setState({ editingChild: null }); + const onAdd = () => { + onSave(); }; - onEdit = async (transaction, name, value) => { - const { transactions } = this.state; - - let newTransaction = { ...transaction, [name]: value }; - if (this.props.onEdit) { - newTransaction = await this.props.onEdit(newTransaction); - } - - const { 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]; + const onEdit = async (transaction, name, value) => { + const newTransaction = { ...transaction, [name]: value }; + await props.onEdit(newTransaction); + onClearActiveEdit(); }; - onClick = (transactionId, name) => { - const { dateFormat } = this.props; - - this.props.pushModal('edit-field', { - name, - onSubmit: (name, value) => { - const { transactions } = this.state; - const 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); - }, + const onClick = (transactionId, name) => { + onRequestActiveEdit?.(getFieldName(transaction.id, 'payee'), () => { + pushModal('edit-field', { + name, + onSubmit: (name, value) => { + const transaction = unserializedTransactions.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 + onEdit(serializeTransaction(transaction, dateFormat), name, value); + }, + onClose: () => { + onClearActiveEdit(); + }, + }); }); }; - onDelete = () => { + const onDelete = id => { + const [transaction, ..._childTransactions] = unserializedTransactions; + const onConfirmDelete = () => { - this.props.onDelete(); + props.onDelete(id); + + if (transaction.id !== id) { + // Only a child transaction was deleted. + onClearActiveEdit(); + return; + } - const { transactions } = this.state; - const [transaction, ..._childTransactions] = transactions; const { account: accountId } = transaction; if (accountId) { - this.props.navigate(`/accounts/${accountId}`, { replace: true }); + navigate(`/accounts/${accountId}`, { replace: true }); } else { - this.props.navigate(-1); + navigate(-1); } }; - const { transactions } = this.state; - const [transaction] = transactions; - if (transaction.reconciled) { - this.props.pushModal('confirm-transaction-edit', { + pushModal('confirm-transaction-edit', { onConfirm: onConfirmDelete, confirmReason: 'deleteReconciled', }); @@ -323,316 +590,322 @@ class TransactionEditInner extends PureComponent { } }; - render() { - const { adding, categories, accounts, payees } = this.props; - 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 - // const forcedSign = transaction.amount < 0 ? 'negative' : 'positive'; - - const account = getAccountsById(accounts)[accountId]; - const isOffBudget = account && !!account.offbudget; - const payee = payees && payeeId && getPayeesById(payees)[payeeId]; - const transferAcct = - payee && - payee.transfer_acct && - getAccountsById(accounts)[payee.transfer_acct]; - const isBudgetTransfer = transferAcct && !transferAcct.offbudget; - const descriptionPretty = getDescriptionPretty( - transaction, - payee, - transferAcct, - ); + const scrollChildTransactionIntoView = id => { + const childTransactionEditElement = + childTransactionElementRefMap.current?.[id]; + childTransactionEditElement?.scrollIntoView({ + behavior: 'smooth', + }); + }; - const transactionDate = parseDate( - transaction.date, - this.props.dateFormat, - new Date(), - ); - const dateDefaultValue = monthUtils.dayFromDate(transactionDate); + const onAddSplit = id => { + props.onAddSplit(id); + }; - return ( - <Page - title={ - payeeId == null - ? adding - ? 'New Transaction' - : 'Transaction' - : descriptionPretty - } - titleStyle={{ - fontSize: 16, - fontWeight: 500, - }} - style={{ - flex: 1, - backgroundColor: theme.mobilePageBackground, - }} - headerLeftContent={<MobileBackButton />} - footer={ - <View - style={{ - paddingLeft: styles.mobileEditingPadding, - paddingRight: styles.mobileEditingPadding, - paddingTop: 10, - paddingBottom: 10, - backgroundColor: theme.tableHeaderBackground, - borderTopWidth: 1, - borderColor: theme.tableBorder, - }} - > - {adding ? ( - <Button style={{ height: 40 }} onClick={() => this.onAdd()}> - <SvgAdd - width={17} - height={17} - style={{ color: theme.formLabelText }} - /> - <Text - style={{ - ...styles.text, - color: theme.formLabelText, - marginLeft: 5, - }} - > - Add transaction - </Text> - </Button> - ) : ( - <Button style={{ height: 40 }} onClick={() => this.onSave()}> - <SvgPencilWriteAlternate - style={{ - width: 16, - height: 16, - color: theme.formInputText, - }} - /> - <Text - style={{ - ...styles.text, - marginLeft: 6, - color: theme.formInputText, - }} - > - Save changes - </Text> - </Button> - )} - </View> - } - padding={0} - > - <View style={{ flexShrink: 0, marginTop: 20, marginBottom: 20 }}> - <View - style={{ - alignItems: 'center', + const onSplit = id => { + props.onSplit(id); + }; + + const onEmptySplitFound = id => { + scrollChildTransactionIntoView(id); + }; + + const transactions = useMemo( + () => + unserializedTransactions.map(t => serializeTransaction(t, dateFormat)) || + [], + [unserializedTransactions, dateFormat], + ); + + const [transaction, ...childTransactions] = transactions; + + useEffect(() => { + const noAmountTransaction = childTransactions.find(t => t.amount === 0); + if (noAmountTransaction) { + scrollChildTransactionIntoView(noAmountTransaction.id); + } + }, [childTransactions]); + + // Child transactions should always default to the signage + // of the parent transaction + const childAmountSign = transaction.amount <= 0 ? '-' : '+'; + + const account = getAccount(transaction); + const isOffBudget = account && !!account.offbudget; + const title = getDescriptionPretty( + transaction, + getPayee(transaction), + getTransferAcct(transaction), + ); + + const transactionDate = parseDate(transaction.date, dateFormat, new Date()); + const dateDefaultValue = monthUtils.dayFromDate(transactionDate); + + return ( + <Page + title={ + transaction.payee == null + ? adding + ? 'New Transaction' + : 'Transaction' + : title + } + titleStyle={{ + fontSize: 16, + fontWeight: 500, + }} + style={{ + flex: 1, + backgroundColor: theme.mobilePageBackground, + }} + headerLeftContent={<MobileBackButton />} + footer={ + <Footer + transactions={transactions} + adding={adding} + onAdd={onAdd} + onSave={onSave} + onSplit={onSplit} + onAddSplit={onAddSplit} + onEmptySplitFound={onEmptySplitFound} + /> + } + padding={0} + > + <View style={{ flexShrink: 0, marginTop: 20, marginBottom: 20 }}> + <View + style={{ + alignItems: 'center', + }} + > + <FieldLabel title="Amount" flush style={{ marginBottom: 0 }} /> + <FocusableAmountInput + value={transaction.amount} + zeroSign="-" + focused={totalAmountFocused} + onFocus={onTotalAmountEdit} + onUpdate={onTotalAmountUpdate} + focusedStyle={{ + width: 'auto', + padding: '5px', + paddingLeft: '20px', + paddingRight: '20px', + minWidth: 120, + transform: [{ translateY: -0.5 }], }} - > - <FieldLabel - title="Amount" - flush - style={{ marginBottom: 0, paddingLeft: 0 }} - /> - <FocusableAmountInput - ref={el => (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' }} - /> - </View> + textStyle={{ fontSize: 30, textAlign: 'center' }} + /> + </View> + + <View> + <FieldLabel title="Payee" /> + <TapField + disabled={ + editingField && + editingField !== getFieldName(transaction.id, 'payee') + } + value={getPrettyPayee(transaction)} + onClick={() => onClick(transaction.id, 'payee')} + data-testid="payee-field" + /> + </View> + {!transaction.is_parent && ( <View> - <FieldLabel title="Payee" /> + <FieldLabel title="Category" /> <TapField - value={descriptionPretty} - onClick={() => this.onClick(transaction.id, 'payee')} - data-testid="payee-field" + style={{ + ...((isOffBudget || isBudgetTransfer(transaction)) && { + fontStyle: 'italic', + color: theme.pageTextSubdued, + fontWeight: 300, + }), + }} + value={getCategory(transaction, isOffBudget)} + disabled={ + (editingField && + editingField !== getFieldName(transaction.id, 'category')) || + isOffBudget || + isBudgetTransfer(transaction) + } + onClick={() => onClick(transaction.id, 'category')} + data-testid="category-field" /> </View> + )} + + {childTransactions.map(childTrans => ( + <ChildTransactionEdit + key={childTrans.id} + transaction={childTrans} + amountSign={childAmountSign} + ref={r => { + childTransactionElementRefMap.current = { + ...childTransactionElementRefMap.current, + [childTrans.id]: r, + }; + }} + isOffBudget={isOffBudget} + getCategory={getCategory} + getPrettyPayee={getPrettyPayee} + isBudgetTransfer={isBudgetTransfer} + onEdit={onEdit} + onClick={onClick} + onDelete={onDelete} + /> + ))} - <View> - <FieldLabel - title={transaction.is_parent ? 'Categories (split)' : 'Category'} - /> - {!transaction.is_parent ? ( - <TapField + {transaction.amount !== 0 && childTransactions.length === 0 && ( + <View style={{ alignItems: 'center' }}> + <Button + disabled={editingField} + style={{ + height: 40, + borderWidth: 0, + marginLeft: styles.mobileEditingPadding, + marginRight: styles.mobileEditingPadding, + marginTop: 10, + backgroundColor: 'transparent', + }} + onClick={() => onSplit(transaction.id)} + type="bare" + > + <Split + width={17} + height={17} + style={{ color: theme.formLabelText }} + /> + <Text style={{ - ...((isBudgetTransfer || isOffBudget) && { - fontStyle: 'italic', - color: theme.pageTextSubdued, - fontWeight: 300, - }), + marginLeft: 5, + userSelect: 'none', + color: theme.formLabelText, }} - value={ - isOffBudget - ? 'Off Budget' - : isBudgetTransfer - ? 'Transfer' - : lookupName(categories, category) - } - disabled={isBudgetTransfer || isOffBudget} - // TODO: the button to turn this transaction into a split - // transaction was on top of the category button in the native - // app, on the right-hand side - // - // On the web this doesn't work well and react gets upset if - // nest a button in a button. - // - // rightContent={ - // <Button - // contentStyle={{ - // paddingVertical: 4, - // paddingHorizontal: 15, - // margin: 0, - // }} - // onPress={this.onSplit} - // > - // Split - // </Button> - // } - onClick={() => this.onClick(transaction.id, 'category')} - data-testid="category-field" - /> - ) : ( - <Text style={{ paddingLeft: styles.mobileEditingPadding }}> - Split transaction editing is not supported on mobile at this - time. + > + Split </Text> - )} - </View> - - <View> - <FieldLabel title="Account" /> - <TapField - disabled={!adding} - value={account ? account.name : null} - onClick={() => this.onClick(transaction.id, 'account')} - data-testid="account-field" - /> - </View> - - <View style={{ flexDirection: 'row' }}> - <View style={{ flex: 1 }}> - <FieldLabel title="Date" /> - <InputField - type="date" - required - style={{ color: theme.tableText, minWidth: '150px' }} - defaultValue={dateDefaultValue} - onUpdate={value => - this.onEdit( - transaction, - 'date', - formatDate(parseISO(value), this.props.dateFormat), - ) - } - onChange={e => - this.onQueueChange( - transaction, - 'date', - formatDate(parseISO(e.target.value), this.props.dateFormat), - ) - } - /> - </View> - {transaction.reconciled ? ( - <View style={{ marginLeft: 0, marginRight: 8 }}> - <FieldLabel title="Reconciled" /> - <BooleanField - checked - style={{ - margin: 'auto', - width: 22, - height: 22, - }} - disabled - /> - </View> - ) : ( - <View style={{ marginLeft: 0, marginRight: 8 }}> - <FieldLabel title="Cleared" /> - <BooleanField - checked={transaction.cleared} - onUpdate={checked => - this.onEdit(transaction, 'cleared', checked) - } - style={{ - margin: 'auto', - width: 22, - height: 22, - }} - /> - </View> - )} + </Button> </View> + )} + + <View> + <FieldLabel title="Account" /> + <TapField + disabled={ + !adding || + (editingField && + editingField !== getFieldName(transaction.id, 'account')) + } + value={account?.name} + onClick={() => onClick(transaction.id, 'account')} + data-testid="account-field" + /> + </View> - <View> - <FieldLabel title="Notes" /> + <View style={{ flexDirection: 'row' }}> + <View style={{ flex: 1 }}> + <FieldLabel title="Date" /> <InputField - defaultValue={transaction.notes} - onUpdate={value => this.onEdit(transaction, 'notes', value)} - onChange={e => - this.onQueueChange(transaction, 'notes', e.target.value) + type="date" + disabled={ + editingField && + editingField !== getFieldName(transaction.id, 'date') + } + required + style={{ color: theme.tableText, minWidth: '150px' }} + defaultValue={dateDefaultValue} + onFocus={() => + onRequestActiveEdit(getFieldName(transaction.id, 'date')) + } + onUpdate={value => + onEdit( + transaction, + 'date', + formatDate(parseISO(value), dateFormat), + ) } /> </View> - - {!adding && ( - <View style={{ alignItems: 'center' }}> - <Button - onClick={() => this.onDelete()} + {transaction.reconciled ? ( + <View style={{ marginLeft: 0, marginRight: 8 }}> + <FieldLabel title="Reconciled" /> + <BooleanField + disabled + checked style={{ - height: 40, - borderWidth: 0, - marginLeft: styles.mobileEditingPadding, - marginRight: styles.mobileEditingPadding, - marginTop: 10, - backgroundColor: 'transparent', + margin: 'auto', + width: 22, + height: 22, }} - type="bare" - > - <SvgTrash - width={17} - height={17} - style={{ color: theme.errorText }} - /> - <Text - style={{ - color: theme.errorText, - marginLeft: 5, - userSelect: 'none', - }} - > - Delete transaction - </Text> - </Button> + /> + </View> + ) : ( + <View style={{ marginLeft: 0, marginRight: 8 }}> + <FieldLabel title="Cleared" /> + <BooleanField + disabled={editingField} + checked={transaction.cleared} + onUpdate={checked => onEdit(transaction, 'cleared', checked)} + style={{ + margin: 'auto', + width: 22, + height: 22, + }} + /> </View> )} </View> - </Page> - ); - } -} + + <View> + <FieldLabel title="Notes" /> + <InputField + disabled={ + editingField && + editingField !== getFieldName(transaction.id, 'notes') + } + defaultValue={transaction.notes} + onFocus={() => { + onRequestActiveEdit(getFieldName(transaction.id, 'notes')); + }} + onUpdate={value => onEdit(transaction, 'notes', value)} + /> + </View> + + {!adding && ( + <View style={{ alignItems: 'center' }}> + <Button + onClick={() => onDelete(transaction.id)} + style={{ + height: 40, + borderWidth: 0, + marginLeft: styles.mobileEditingPadding, + marginRight: styles.mobileEditingPadding, + marginTop: 10, + backgroundColor: 'transparent', + }} + type="bare" + > + <Trash + width={17} + height={17} + style={{ color: theme.errorText }} + /> + <Text + style={{ + color: theme.errorText, + marginLeft: 5, + userSelect: 'none', + }} + > + Delete transaction + </Text> + </Button> + </View> + )} + </View> + </Page> + ); +}); function isTemporary(transaction) { return transaction.id.indexOf('temp') === 0; @@ -654,10 +927,10 @@ function TransactionEditUnconnected(props) { const { categories, accounts, payees, lastTransaction, dateFormat } = props; const { id: accountId, transactionId } = useParams(); const navigate = useNavigate(); - const [fetchedTransactions, setFetchedTransactions] = useState(null); - let transactions = []; - let adding = false; - let deleted = false; + const [transactions, setTransactions] = useState([]); + const [fetchedTransactions, setFetchedTransactions] = useState([]); + const adding = useRef(false); + const deleted = useRef(false); useSetThemeColor(theme.mobileViewTheme); useEffect(() => { @@ -667,56 +940,62 @@ function TransactionEditUnconnected(props) { 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. - const { data } = await runQuery( - q('transactions') - .filter({ id: transactionId }) - .select('*') - .options({ splits: 'grouped' }), - ); - transactions = ungroupTransactions(data); - setFetchedTransactions(transactions); - } + // 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. + const { data } = await runQuery( + q('transactions') + .filter({ id: transactionId }) + .select('*') + .options({ splits: 'grouped' }), + ); + setFetchedTransactions(ungroupTransactions(data)); + } + if (transactionId) { + fetchTransaction(); + } else { + adding.current = true; } - fetchTransaction(); }, [transactionId]); + useEffect(() => { + setTransactions(fetchedTransactions); + }, [fetchedTransactions]); + + useEffect(() => { + if (adding.current) { + setTransactions( + makeTemporaryTransactions( + accountId || lastTransaction?.account || null, + lastTransaction?.date, + ), + ); + } + }, [adding.current, accountId, lastTransaction]); + if ( categories.length === 0 || accounts.length === 0 || - (transactionId && !fetchedTransactions) + transactions.length === 0 ) { return null; } - if (!transactionId) { - transactions = makeTemporaryTransactions( - accountId || (lastTransaction && lastTransaction.account) || null, - lastTransaction && lastTransaction.date, - ); - adding = true; - } else { - transactions = fetchedTransactions; - } - const onEdit = async transaction => { + let newTransaction = 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)) { const afterRules = await send('rules-run', { transaction }); const diff = getChangedValues(transaction, afterRules); - const newTransaction = { ...transaction }; + newTransaction = { ...transaction }; if (diff) { Object.keys(diff).forEach(field => { if (newTransaction[field] == null) { @@ -724,18 +1003,21 @@ function TransactionEditUnconnected(props) { } }); } - return newTransaction; } - return transaction; + const { data: newTransactions } = updateTransaction( + transactions, + deserializeTransaction(newTransaction, null, dateFormat), + ); + setTransactions(newTransactions); }; const onSave = async newTransactions => { - if (deleted) { + if (deleted.current) { return; } - const changes = diffItems(transactions || [], newTransactions); + const changes = diffItems(fetchedTransactions || [], newTransactions); if ( changes.added.length > 0 || changes.updated.length > 0 || @@ -755,24 +1037,40 @@ function TransactionEditUnconnected(props) { // } } - if (adding) { + if (adding.current) { // The first one is always the "parent" and the only one we care // about props.setLastTransaction(newTransactions[0]); } }; - const onDelete = async () => { - if (adding) { + const onDelete = async id => { + const changes = deleteTransaction(transactions, id); + + if (adding.current) { // Adding a new transactions, this disables saving when the component unmounts - deleted = true; + deleted.current = true; } else { - const changes = { deleted: transactions }; - const _remoteUpdates = await send('transactions-batch-update', changes); + const _remoteUpdates = await send('transactions-batch-update', { + deleted: changes.diff.deleted, + }); + // if (onTransactionsChange) { // onTransactionsChange({ ...changes, updated: remoteUpdates }); // } } + + setTransactions(changes.data); + }; + + const onAddSplit = id => { + const changes = addSplitTransaction(transactions, id); + setTransactions(changes.data); + }; + + const onSplit = id => { + const changes = splitTransaction(transactions, id); + setTransactions(changes.data); }; return ( @@ -784,21 +1082,18 @@ function TransactionEditUnconnected(props) { > <TransactionEditInner transactions={transactions} - adding={adding} + adding={adding.current} categories={categories} accounts={accounts} payees={payees} pushModal={props.pushModal} navigate={navigate} - // TODO: ChildEdit is complicated and heavily relies on RN - // renderChildEdit={props => <ChildEdit {...props} />} - renderChildEdit={props => {}} dateFormat={dateFormat} - // TODO: was this a mistake in the original code? - // onTapField={this.onTapField} onEdit={onEdit} onSave={onSave} onDelete={onDelete} + onSplit={onSplit} + onAddSplit={onAddSplit} /> </View> ); @@ -815,188 +1110,198 @@ export const TransactionEdit = props => { const actions = useActions(); return ( - <TransactionEditUnconnected - {...props} - {...actions} - categories={categories} - payees={payees} - lastTransaction={lastTransaction} - accounts={accounts} - dateFormat={dateFormat} - /> + <SingleActiveEditFormProvider formName="mobile-transaction"> + <TransactionEditUnconnected + {...props} + {...actions} + categories={categories} + payees={payees} + lastTransaction={lastTransaction} + accounts={accounts} + dateFormat={dateFormat} + /> + </SingleActiveEditFormProvider> ); }; -class Transaction extends PureComponent { - render() { - const { - transaction, - accounts, - categories, - payees, - showCategory, - added, - onSelect, - style, - } = this.props; - const { - id, - payee: payeeId, - amount: originalAmount, - category, - cleared, - is_parent, - notes, - schedule, - } = transaction; - - let amount = originalAmount; - if (isPreviewId(id)) { - amount = getScheduledAmount(amount); - } +const Transaction = memo(function Transaction({ + transaction, + accounts, + categories, + payees, + showCategory, + added, + onSelect, + style, +}) { + const accountsById = useMemo(() => groupById(accounts), [accounts]); + const payeesById = useMemo(() => groupById(payees), [payees]); + + const { + id, + payee: payeeId, + amount: originalAmount, + category: categoryId, + cleared, + is_parent: isParent, + notes, + schedule, + } = transaction; + + let amount = originalAmount; + if (isPreviewId(id)) { + amount = getScheduledAmount(amount); + } - const categoryName = lookupName(categories, category); + const categoryName = lookupName(categories, categoryId); - const payee = payees && payeeId && getPayeesById(payees)[payeeId]; - const transferAcct = - payee && - payee.transfer_acct && - getAccountsById(accounts)[payee.transfer_acct]; + const payee = payeesById && payeeId && payeesById[payeeId]; + const transferAcct = + payee && payee.transfer_acct && accountsById[payee.transfer_acct]; - const prettyDescription = getDescriptionPretty( - transaction, - payee, - transferAcct, - ); - const prettyCategory = transferAcct - ? 'Transfer' - : is_parent - ? 'Split' - : categoryName; - - const isPreview = isPreviewId(id); - const isReconciled = transaction.reconciled; - const textStyle = isPreview && { - fontStyle: 'italic', - color: theme.pageTextLight, - }; + const prettyDescription = getDescriptionPretty( + transaction, + payee, + transferAcct, + ); + const prettyCategory = transferAcct + ? 'Transfer' + : isParent + ? 'Split' + : categoryName; + + const isPreview = isPreviewId(id); + const isReconciled = transaction.reconciled; + const textStyle = isPreview && { + fontStyle: 'italic', + color: theme.pageTextLight, + }; - return ( - <Button - onClick={() => onSelect(transaction)} + return ( + <Button + onClick={() => onSelect(transaction)} + style={{ + backgroundColor: theme.tableBackground, + border: 'none', + width: '100%', + }} + > + <ListItem style={{ - backgroundColor: theme.tableBackground, - border: 'none', - width: '100%', + flex: 1, + height: 60, + padding: '5px 10px', // remove padding when Button is back + ...(isPreview && { + backgroundColor: theme.tableRowHeaderBackground, + }), + ...style, }} > - <ListItem - style={{ - flex: 1, - height: 60, - padding: '5px 10px', // remove padding when Button is back - ...(isPreview && { - backgroundColor: theme.tableRowHeaderBackground, - }), - ...style, - }} - > - <View style={{ flex: 1 }}> - <View style={{ flexDirection: 'row', alignItems: 'center' }}> - {schedule && ( - <ArrowsSynchronize + <View style={{ flex: 1 }}> + <View style={{ flexDirection: 'row', alignItems: 'center' }}> + {schedule && ( + <ArrowsSynchronize + style={{ + width: 12, + height: 12, + marginRight: 5, + color: textStyle.color || theme.menuItemText, + }} + /> + )} + <TextOneLine + style={{ + ...styles.text, + ...textStyle, + fontSize: 14, + fontWeight: added ? '600' : '400', + ...(prettyDescription === '' && { + color: theme.tableTextLight, + fontStyle: 'italic', + }), + }} + > + {prettyDescription || 'Empty'} + </TextOneLine> + </View> + {isPreview ? ( + <Status status={notes} /> + ) : ( + <View + style={{ + flexDirection: 'row', + alignItems: 'center', + marginTop: 3, + }} + > + {isReconciled ? ( + <Lock + style={{ + width: 11, + height: 11, + color: theme.noticeTextLight, + marginRight: 5, + }} + /> + ) : ( + <CheckCircle1 style={{ - width: 12, - height: 12, + width: 11, + height: 11, + color: cleared + ? theme.noticeTextLight + : theme.pageTextSubdued, marginRight: 5, - color: textStyle.color || theme.menuItemText, }} /> )} - <TextOneLine - style={{ - ...styles.text, - ...textStyle, - fontSize: 14, - fontWeight: added ? '600' : '400', - ...(prettyDescription === '' && { - color: theme.tableTextLight, - fontStyle: 'italic', - }), - }} - > - {prettyDescription || 'Empty'} - </TextOneLine> + {showCategory && ( + <TextOneLine + style={{ + fontSize: 11, + marginTop: 1, + fontWeight: '400', + color: prettyCategory + ? theme.tableTextSelected + : theme.menuItemTextSelected, + fontStyle: prettyCategory ? null : 'italic', + textAlign: 'left', + }} + > + {prettyCategory || 'Uncategorized'} + </TextOneLine> + )} </View> - {isPreview ? ( - <Status status={notes} /> - ) : ( - <View - style={{ - flexDirection: 'row', - alignItems: 'center', - marginTop: 3, - }} - > - {isReconciled ? ( - <Lock - style={{ - width: 11, - height: 11, - color: theme.noticeTextLight, - marginRight: 5, - }} - /> - ) : ( - <CheckCircle1 - style={{ - width: 11, - height: 11, - color: cleared - ? theme.noticeTextLight - : theme.pageTextSubdued, - marginRight: 5, - }} - /> - )} - {showCategory && ( - <TextOneLine - style={{ - fontSize: 11, - marginTop: 1, - fontWeight: '400', - color: prettyCategory - ? theme.tableTextSelected - : theme.menuItemTextSelected, - fontStyle: prettyCategory ? null : 'italic', - textAlign: 'left', - }} - > - {prettyCategory || 'Uncategorized'} - </TextOneLine> - )} - </View> - )} - </View> - <Text - style={{ - ...styles.text, - ...textStyle, - marginLeft: 25, - marginRight: 5, - fontSize: 14, - }} - > - {integerToCurrency(amount)} - </Text> - </ListItem> - </Button> - ); - } -} + )} + </View> + <Text + style={{ + ...styles.text, + ...textStyle, + marginLeft: 25, + marginRight: 5, + fontSize: 14, + }} + > + {integerToCurrency(amount)} + </Text> + </ListItem> + </Button> + ); +}); -export class TransactionList extends Component { - makeData = memoizeOne(transactions => { +export function TransactionList({ + accounts, + categories, + payees, + transactions, + showCategory, + isNew, + onSelect, + scrollProps = {}, + onLoadMore, +}) { + const sections = useMemo(() => { // Group by date. We can assume transactions is ordered const sections = []; transactions.forEach(transaction => { @@ -1026,78 +1331,70 @@ export class TransactionList extends Component { } }); return sections; - }); + }, [transactions]); - render() { - const { transactions, scrollProps = {}, onLoadMore } = this.props; - - const sections = this.makeData(transactions); - - return ( - <> - {scrollProps.ListHeaderComponent} - <ListBox - {...scrollProps} - aria-label="transaction list" - label="" - loadMore={onLoadMore} - selectionMode="none" - > - {sections.length === 0 ? ( - <Section> - <Item textValue="No transactions"> - <div - style={{ - display: 'flex', - justifyContent: 'center', - width: '100%', - backgroundColor: theme.mobilePageBackground, - }} - > - <Text style={{ fontSize: 15 }}>No transactions</Text> - </div> - </Item> - </Section> - ) : null} - {sections.map(section => { - return ( - <Section - title={ - <span> - {monthUtils.format(section.date, 'MMMM dd, yyyy')} - </span> - } - key={section.id} + return ( + <> + {scrollProps.ListHeaderComponent} + <ListBox + {...scrollProps} + aria-label="transaction list" + label="" + loadMore={onLoadMore} + selectionMode="none" + > + {sections.length === 0 ? ( + <Section> + <Item textValue="No transactions"> + <div + style={{ + display: 'flex', + justifyContent: 'center', + width: '100%', + backgroundColor: theme.mobilePageBackground, + }} > - {section.data.map((transaction, index, transactions) => { - return ( - <Item - key={transaction.id} - style={{ - fontSize: - index === transactions.length - 1 ? 98 : 'inherit', - }} - textValue={transaction.id} - > - <Transaction - transaction={transaction} - categories={this.props.categories} - accounts={this.props.accounts} - payees={this.props.payees} - showCategory={this.props.showCategory} - added={this.props.isNew(transaction.id)} - onSelect={this.props.onSelect} // onSelect(transaction)} - /> - </Item> - ); - })} - </Section> - ); - })} - </ListBox> - </> - ); - } + <Text style={{ fontSize: 15 }}>No transactions</Text> + </div> + </Item> + </Section> + ) : null} + {sections.map(section => { + return ( + <Section + title={ + <span>{monthUtils.format(section.date, 'MMMM dd, yyyy')}</span> + } + key={section.id} + > + {section.data.map((transaction, index, transactions) => { + return ( + <Item + key={transaction.id} + style={{ + fontSize: + index === transactions.length - 1 ? 98 : 'inherit', + }} + textValue={transaction.id} + > + <Transaction + transaction={transaction} + categories={categories} + accounts={accounts} + payees={payees} + showCategory={showCategory} + added={isNew(transaction.id)} + onSelect={onSelect} // onSelect(transaction)} + /> + </Item> + ); + })} + </Section> + ); + })} + </ListBox> + </> + ); } function ListBox(props) { @@ -1203,7 +1500,6 @@ function Option({ isLast, item, state }) { const { optionProps, isSelected } = useOption({ key: item.key }, state, ref); // Determine whether we should show a keyboard - // focus ring for accessibility const { isFocusVisible, focusProps } = useFocusRing(); return ( diff --git a/packages/desktop-client/src/components/util/AmountInput.tsx b/packages/desktop-client/src/components/util/AmountInput.tsx index 632082cf9eac2ddf10e8e4d62b7ab24b2d5dba21..9adec5f3adb4384cecb84beaa480dc06eee4a5ad 100644 --- a/packages/desktop-client/src/components/util/AmountInput.tsx +++ b/packages/desktop-client/src/components/util/AmountInput.tsx @@ -1,4 +1,10 @@ -import React, { type Ref, useRef, useState, useEffect } from 'react'; +import React, { + type Ref, + useRef, + useState, + useEffect, + type FocusEventHandler, +} from 'react'; import evalArithmetic from 'loot-core/src/shared/arithmetic'; import { amountToInteger } from 'loot-core/src/shared/util'; @@ -15,69 +21,75 @@ import useFormat from '../spreadsheet/useFormat'; type AmountInputProps = { id?: string; inputRef?: Ref<HTMLInputElement>; - initialValue: number; + value: number; zeroSign?: '-' | '+'; - onChange?: (value: number) => void; - onBlur?: () => void; + onChange?: (value: string) => void; + onFocus?: FocusEventHandler<HTMLInputElement>; + onBlur?: FocusEventHandler<HTMLInputElement>; + onUpdate?: (amount: number) => void; style?: CSSProperties; textStyle?: CSSProperties; focused?: boolean; + disabled?: boolean; }; export function AmountInput({ id, inputRef, - initialValue, + value: initialValue, zeroSign = '-', // + or - - onChange, + onFocus, onBlur, + onChange, + onUpdate, style, textStyle, focused, + disabled = false, + ...props }: AmountInputProps) { const format = useFormat(); - const [negative, setNegative] = useState( - (initialValue === 0 && zeroSign === '-') || initialValue < 0, - ); + const negative = (initialValue === 0 && zeroSign === '-') || initialValue < 0; - const initialValueAbsolute = format(Math.abs(initialValue), 'financial'); + const initialValueAbsolute = format(Math.abs(initialValue || 0), 'financial'); const [value, setValue] = useState(initialValueAbsolute); useEffect(() => setValue(initialValueAbsolute), [initialValueAbsolute]); const buttonRef = useRef(); + const ref = useRef<HTMLInputElement>(); + const mergedRef = useMergedRefs<HTMLInputElement>(inputRef, ref); + + useEffect(() => { + if (focused) { + ref.current?.focus(); + } + }, [focused]); function onSwitch() { - setNegative(!negative); - fireChange(value, !negative); + fireUpdate(!negative); } - function fireChange(val, neg) { + function getAmount(negate) { const valueOrInitial = Math.abs( - amountToInteger(evalArithmetic(val, initialValueAbsolute)), + amountToInteger(evalArithmetic(value, initialValueAbsolute)), ); - const amount = neg ? valueOrInitial * -1 : valueOrInitial; - - onChange?.(amount); + return negate ? valueOrInitial * -1 : valueOrInitial; } - function onInputAmountChange(value) { - setValue(value ? value : ''); + function onInputTextChange(val) { + setValue(val ? val : ''); + onChange?.(val); } - const ref = useRef<HTMLInputElement>(); - const mergedRef = useMergedRefs<HTMLInputElement>(inputRef, ref); - - useEffect(() => { - if (focused) { - ref.current?.focus(); - } - }, [focused]); + function fireUpdate(negate) { + onUpdate?.(getAmount(negate)); + } function onInputAmountBlur(e) { - fireChange(value, negative); if (!ref.current?.contains(e.relatedTarget)) { - onBlur?.(); + fireUpdate(negative); } + onBlur?.(e); } return ( @@ -88,6 +100,7 @@ export function AmountInput({ leftContent={ <Button type="bare" + disabled={disabled} aria-label={`Make ${negative ? 'positive' : 'negative'}`} style={{ padding: '0 7px' }} onPointerUp={onSwitch} @@ -102,17 +115,18 @@ export function AmountInput({ </Button> } value={value} + disabled={disabled} focused={focused} style={{ flex: 1, alignItems: 'stretch', ...style }} inputStyle={{ paddingLeft: 0, ...textStyle }} onKeyUp={e => { if (e.key === 'Enter') { - fireChange(value, negative); - onBlur?.(); + fireUpdate(negative); } }} - onUpdate={onInputAmountChange} + onUpdate={onInputTextChange} onBlur={onInputAmountBlur} + onFocus={onFocus} /> ); } @@ -124,8 +138,8 @@ export function BetweenAmountInput({ defaultValue, onChange }) { return ( <View style={{ flexDirection: 'row', alignItems: 'center' }}> <AmountInput - initialValue={num1} - onChange={value => { + value={num1} + onUpdate={value => { setNum1(value); onChange({ num1: value, num2 }); }} @@ -133,8 +147,8 @@ export function BetweenAmountInput({ defaultValue, onChange }) { /> <View style={{ margin: '0 5px' }}>and</View> <AmountInput - initialValue={num2} - onChange={value => { + value={num2} + onUpdate={value => { setNum2(value); onChange({ num1, num2: value }); }} diff --git a/packages/desktop-client/src/hooks/useSingleActiveEditForm.tsx b/packages/desktop-client/src/hooks/useSingleActiveEditForm.tsx new file mode 100644 index 0000000000000000000000000000000000000000..662c3c3fcbb39e7e54f4c5408755ca1e2b73296e --- /dev/null +++ b/packages/desktop-client/src/hooks/useSingleActiveEditForm.tsx @@ -0,0 +1,125 @@ +import React, { + type ReactNode, + createContext, + useContext, + useState, + useRef, + useEffect, +} from 'react'; + +import usePrevious from './usePrevious'; +import useStableCallback from './useStableCallback'; + +type ActiveEditCleanup = () => void; +type ActiveEditAction = () => void | ActiveEditCleanup; + +type SingleActiveEditFormContextValue = { + formName: string; + editingField: string; + onRequestActiveEdit: ( + field: string, + action?: ActiveEditAction, + clearActiveEditDelayMs?: number, + ) => void; + onClearActiveEdit: (delayMs?: number) => void; +}; + +const SingleActiveEditFormContext = createContext< + SingleActiveEditFormContextValue | undefined +>(undefined); + +type SingleActiveEditFormProviderProps = { + formName: string; + children: ReactNode; +}; + +export function SingleActiveEditFormProvider({ + formName, + children, +}: SingleActiveEditFormProviderProps) { + const [editingField, setEditingField] = useState(null); + const prevEditingField = usePrevious(editingField); + const actionRef = useRef<ActiveEditAction>(null); + const cleanupRef = useRef<ActiveEditCleanup | void>(null); + + useEffect(() => { + if (prevEditingField != null && prevEditingField !== editingField) { + runCleanup(); + } else if (prevEditingField == null && editingField !== null) { + runAction(); + } + }, [editingField]); + + const runAction = () => { + cleanupRef.current = actionRef.current?.(); + }; + + const runCleanup = () => { + const editCleanup = cleanupRef.current; + if (typeof editCleanup === 'function') { + editCleanup?.(); + } + cleanupRef.current = null; + }; + + const onClearActiveEdit = (delayMs?: number) => { + setTimeout(() => setEditingField(null), delayMs); + }; + + const onRequestActiveEdit = useStableCallback( + ( + field: string, + action: ActiveEditAction, + options: { + clearActiveEditDelayMs?: number; + }, + ) => { + if (editingField === field) { + // Already active. + return; + } + + if (editingField) { + onClearActiveEdit(options?.clearActiveEditDelayMs); + } else { + actionRef.current = action; + setEditingField(field); + } + }, + ); + + return ( + <SingleActiveEditFormContext.Provider + value={{ + formName, + editingField, + onRequestActiveEdit, + onClearActiveEdit, + }} + > + {children} + </SingleActiveEditFormContext.Provider> + ); +} + +type UseSingleActiveEditFormResult = { + formName: SingleActiveEditFormContextValue['formName']; + editingField?: SingleActiveEditFormContextValue['editingField']; + onRequestActiveEdit: SingleActiveEditFormContextValue['onRequestActiveEdit']; + onClearActiveEdit: SingleActiveEditFormContextValue['onClearActiveEdit']; +}; + +export function useSingleActiveEditForm(): UseSingleActiveEditFormResult | null { + const context = useContext(SingleActiveEditFormContext); + + if (!context) { + return null; + } + + return { + formName: context.formName, + editingField: context.editingField, + onRequestActiveEdit: context.onRequestActiveEdit, + onClearActiveEdit: context.onClearActiveEdit, + }; +} diff --git a/packages/loot-core/src/client/state-types/modals.d.ts b/packages/loot-core/src/client/state-types/modals.d.ts index 1c12c19f09d4d07da5b681ae27deee0be03ee95d..a5b0396c404d4f81fa16f1b5d69448989431571f 100644 --- a/packages/loot-core/src/client/state-types/modals.d.ts +++ b/packages/loot-core/src/client/state-types/modals.d.ts @@ -90,6 +90,7 @@ type FinanceModals = { 'edit-field': { name: string; onSubmit: (name: string, value: string) => void; + onClose: () => void; }; 'budget-summary': { diff --git a/upcoming-release-notes/2068.md b/upcoming-release-notes/2068.md new file mode 100644 index 0000000000000000000000000000000000000000..a4afd516438adc2c103cf7f6250585ec4ca9e43e --- /dev/null +++ b/upcoming-release-notes/2068.md @@ -0,0 +1,6 @@ +--- +category: Features +authors: [joel-jeremy] +--- + +Mobile split transactions