diff --git a/packages/desktop-client/src/components/FinancesApp.tsx b/packages/desktop-client/src/components/FinancesApp.tsx index 57deef99033e77611d480ec14d40fd320fd49178..4f5b0f8d269016cb5460bdf71674ec33319c3391 100644 --- a/packages/desktop-client/src/components/FinancesApp.tsx +++ b/packages/desktop-client/src/components/FinancesApp.tsx @@ -43,6 +43,7 @@ import { NarrowAlternate, WideComponent } from './responsive'; import PostsOfflineNotification from './schedules/PostsOfflineNotification'; import Settings from './settings'; import Titlebar, { TitlebarProvider } from './Titlebar'; +import { TransactionEdit } from './transactions/MobileTransaction'; function NarrowNotSupported({ redirectTo = '/budget', @@ -61,6 +62,17 @@ function NarrowNotSupported({ return isNarrowWidth ? null : children; } +function WideNotSupported({ children, redirectTo = '/budget' }) { + const { isNarrowWidth } = useResponsive(); + const navigate = useNavigate(); + useEffect(() => { + if (!isNarrowWidth) { + navigate(redirectTo); + } + }, [isNarrowWidth, navigate, redirectTo]); + return isNarrowWidth ? children : null; +} + function StackedRoutesInner({ location }) { return ( <Routes location={location}> @@ -147,12 +159,30 @@ function StackedRoutesInner({ location }) { } /> + <Route path="/accounts" element={<NarrowAlternate name="Accounts" />} /> + <Route path="/accounts/:id" element={<NarrowAlternate name="Account" />} /> - <Route path="/accounts" element={<NarrowAlternate name="Accounts" />} /> + <Route + path="/accounts/:id/transactions/:transactionId" + element={ + <WideNotSupported> + <TransactionEdit /> + </WideNotSupported> + } + /> + + <Route + path="/accounts/:id/transactions/new" + element={ + <WideNotSupported> + <TransactionEdit /> + </WideNotSupported> + } + /> </Routes> ); } diff --git a/packages/desktop-client/src/components/accounts/MobileAccount.js b/packages/desktop-client/src/components/accounts/MobileAccount.js index 720596e54cc801796803cdae5b83e811453085a3..4a11fd30d9046164feefa3793c178e6f426021df 100644 --- a/packages/desktop-client/src/components/accounts/MobileAccount.js +++ b/packages/desktop-client/src/components/accounts/MobileAccount.js @@ -15,7 +15,6 @@ import * as queries from 'loot-core/src/client/queries'; import { pagedQuery } from 'loot-core/src/client/query-helpers'; import { send, listen } from 'loot-core/src/platform/client/fetch'; import { - getSplit, isPreviewId, ungroupTransactions, } from 'loot-core/src/shared/transactions'; @@ -185,7 +184,6 @@ export default function Account(props) { setSearchText(text); }; - // eslint-disable-next-line @typescript-eslint/no-unused-vars const onSelectTransaction = transaction => { if (isPreviewId(transaction.id)) { let parts = transaction.id.split('/'); @@ -214,17 +212,7 @@ export default function Account(props) { }, ); } else { - let trans = [transaction]; - if (transaction.parent_id || transaction.is_parent) { - let index = transactions.findIndex( - t => t.id === (transaction.parent_id || transaction.id), - ); - trans = getSplit(transactions, index); - } - - navigate('Transaction', { - transactions: trans, - }); + navigate(`transactions/${transaction.id}`); } }; @@ -269,7 +257,7 @@ export default function Account(props) { paged?.fetchNext(); }} onSearch={onSearch} - onSelectTransaction={() => {}} // onSelectTransaction} + onSelectTransaction={onSelectTransaction} /> ) } diff --git a/packages/desktop-client/src/components/accounts/MobileAccountDetails.js b/packages/desktop-client/src/components/accounts/MobileAccountDetails.js index 4c9ecb5b72df34330ce807d28c4a6abf1c8716b7..88281b6f02808f23d53e0197c201fee38ab9503c 100644 --- a/packages/desktop-client/src/components/accounts/MobileAccountDetails.js +++ b/packages/desktop-client/src/components/accounts/MobileAccountDetails.js @@ -133,7 +133,7 @@ export default function AccountDetails({ TODO: connect to an add transaction modal Only left here but hidden for flex centering of the account name. */} - <Link to="transaction/new" style={{ visibility: 'hidden' }}> + <Link to="transactions/new"> <Button type="bare" style={{ justifyContent: 'center', width: LEFT_RIGHT_FLEX_WIDTH }} diff --git a/packages/desktop-client/src/components/autocomplete/AccountAutocomplete.js b/packages/desktop-client/src/components/autocomplete/AccountAutocomplete.js index a0c693fc38ea56863e1187396209fee63f3a0c97..f1918eda80b7944a5a7f8b9aef1ec98993ba22ae 100644 --- a/packages/desktop-client/src/components/autocomplete/AccountAutocomplete.js +++ b/packages/desktop-client/src/components/autocomplete/AccountAutocomplete.js @@ -1,14 +1,27 @@ import React from 'react'; +import { css } from 'glamor'; + import { useCachedAccounts } from 'loot-core/src/client/data-hooks/accounts'; +import { useResponsive } from '../../ResponsiveProvider'; import { colors } from '../../style'; import View from '../common/View'; import Autocomplete from './Autocomplete'; -function AccountList({ items, getItemProps, highlightedIndex, embedded }) { +function AccountList({ + items, + getItemProps, + highlightedIndex, + embedded, + groupHeaderStyle, +}) { let lastItem = null; + const { isNarrowWidth } = useResponsive(); + const highlightedIndexColor = isNarrowWidth + ? 'rgba(100, 100, 100, .15)' + : colors.n4; return ( <View> @@ -41,6 +54,7 @@ function AccountList({ items, getItemProps, highlightedIndex, embedded }) { style={{ color: colors.y9, padding: '4px 9px', + ...groupHeaderStyle, }} data-testid="account-item-group" > @@ -49,14 +63,43 @@ function AccountList({ items, getItemProps, highlightedIndex, embedded }) { ) : null, <div {...(getItemProps ? getItemProps({ item }) : null)} + // Downshift calls `setTimeout(..., 250)` in the `onMouseMove` + // event handler they set on this element. When this code runs + // in WebKit on touch-enabled devices, taps on this element end + // up not triggering the `onClick` event (and therefore delaying + // response to user input) until after the `setTimeout` callback + // finishes executing. This is caused by content observation code + // that implements various strategies to prevent the user from + // accidentally clicking content that changed as a result of code + // run in the `onMouseMove` event. + // + // Long story short, we don't want any delay here between the user + // tapping and the resulting action being performed. It turns out + // there's some "fast path" logic that can be triggered in various + // ways to force WebKit to bail on the content observation process. + // One of those ways is setting `role="button"` (or a number of + // other aria roles) on the element, which is what we're doing here. + // + // ref: + // * https://github.com/WebKit/WebKit/blob/447d90b0c52b2951a69df78f06bb5e6b10262f4b/LayoutTests/fast/events/touch/ios/content-observation/400ms-hover-intent.html + // * https://github.com/WebKit/WebKit/blob/58956cf59ba01267644b5e8fe766efa7aa6f0c5c/Source/WebCore/page/ios/ContentChangeObserver.cpp + // * https://github.com/WebKit/WebKit/blob/58956cf59ba01267644b5e8fe766efa7aa6f0c5c/Source/WebKit/WebProcess/WebPage/ios/WebPageIOS.mm#L783 + role="button" key={item.id} - style={{ - backgroundColor: - highlightedIndex === idx ? colors.n4 : 'transparent', - padding: 4, - paddingLeft: 20, - borderRadius: embedded ? 4 : 0, - }} + className={`${css([ + { + backgroundColor: + highlightedIndex === idx + ? highlightedIndexColor + : 'transparent', + padding: 4, + paddingLeft: 20, + borderRadius: embedded ? 4 : 0, + ':active': { + backgroundColor: 'rgba(100, 100, 100, .25)', + }, + }, + ])}`} data-testid={ 'account-item' + (highlightedIndex === idx ? '-highlighted' : '') @@ -74,6 +117,8 @@ function AccountList({ items, getItemProps, highlightedIndex, embedded }) { export default function AccountAutocomplete({ embedded, includeClosedAccounts = true, + groupHeaderStyle, + closeOnBlur, ...props }) { let accounts = useCachedAccounts() || []; @@ -97,6 +142,7 @@ export default function AccountAutocomplete({ strict={true} highlightFirst={true} embedded={embedded} + closeOnBlur={closeOnBlur} suggestions={accounts} renderItems={(items, getItemProps, highlightedIndex) => ( <AccountList @@ -104,6 +150,7 @@ export default function AccountAutocomplete({ getItemProps={getItemProps} highlightedIndex={highlightedIndex} embedded={embedded} + groupHeaderStyle={groupHeaderStyle} /> )} {...props} diff --git a/packages/desktop-client/src/components/autocomplete/Autocomplete.tsx b/packages/desktop-client/src/components/autocomplete/Autocomplete.tsx index 0772f71d9ab0932ef0beb0613d107bc33ad6fc9f..600db987c02e6760a02b653af2acf67204720171 100644 --- a/packages/desktop-client/src/components/autocomplete/Autocomplete.tsx +++ b/packages/desktop-client/src/components/autocomplete/Autocomplete.tsx @@ -94,6 +94,28 @@ function defaultRenderItems(items, getItemProps, highlightedIndex) { return ( <div {...getItemProps({ item })} + // Downshift calls `setTimeout(..., 250)` in the `onMouseMove` + // event handler they set on this element. When this code runs + // in WebKit on touch-enabled devices, taps on this element end + // up not triggering the `onClick` event (and therefore delaying + // response to user input) until after the `setTimeout` callback + // finishes executing. This is caused by content observation code + // that implements various strategies to prevent the user from + // accidentally clicking content that changed as a result of code + // run in the `onMouseMove` event. + // + // Long story short, we don't want any delay here between the user + // tapping and the resulting action being performed. It turns out + // there's some "fast path" logic that can be triggered in various + // ways to force WebKit to bail on the content observation process. + // One of those ways is setting `role="button"` (or a number of + // other aria roles) on the element, which is what we're doing here. + // + // ref: + // * https://github.com/WebKit/WebKit/blob/447d90b0c52b2951a69df78f06bb5e6b10262f4b/LayoutTests/fast/events/touch/ios/content-observation/400ms-hover-intent.html + // * https://github.com/WebKit/WebKit/blob/58956cf59ba01267644b5e8fe766efa7aa6f0c5c/Source/WebCore/page/ios/ContentChangeObserver.cpp + // * https://github.com/WebKit/WebKit/blob/58956cf59ba01267644b5e8fe766efa7aa6f0c5c/Source/WebKit/WebProcess/WebPage/ios/WebPageIOS.mm#L783 + role="button" key={name} {...css({ padding: 5, @@ -143,6 +165,7 @@ type SingleAutocompleteProps = { strict?: boolean; onSelect: (id: unknown, value: string) => void; tableBehavior?: boolean; + closeOnBlur?: boolean; value: unknown[]; isMulti?: boolean; }; @@ -167,6 +190,7 @@ function SingleAutocomplete({ strict, onSelect, tableBehavior, + closeOnBlur = true, value: initialValue, isMulti = false, }: SingleAutocompleteProps) { @@ -377,6 +401,8 @@ function SingleAutocomplete({ e.preventDownshiftDefault = true; inputProps.onBlur?.(e); + if (!closeOnBlur) return; + if (!tableBehavior) { if (e.target.value === '') { onSelect?.(null, e.target.value); diff --git a/packages/desktop-client/src/components/autocomplete/CategorySelect.tsx b/packages/desktop-client/src/components/autocomplete/CategorySelect.tsx index c10221373e7e6b59a27b07c775a17341e78a52bf..2fddd52ed9dd3a6a4ff0dd30bb8e16627328271d 100644 --- a/packages/desktop-client/src/components/autocomplete/CategorySelect.tsx +++ b/packages/desktop-client/src/components/autocomplete/CategorySelect.tsx @@ -5,7 +5,10 @@ import React, { type ReactNode, } from 'react'; +import { css } from 'glamor'; + import Split from '../../icons/v0/Split'; +import { useResponsive } from '../../ResponsiveProvider'; import { colors } from '../../style'; import Text from '../common/Text'; import View from '../common/View'; @@ -31,6 +34,7 @@ export type CategoryListProps = { highlightedIndex: number; embedded: boolean; footer?: ReactNode; + groupHeaderStyle?: object; }; function CategoryList({ items, @@ -38,8 +42,13 @@ function CategoryList({ highlightedIndex, embedded, footer, + groupHeaderStyle, }: CategoryListProps) { let lastGroup = null; + const { isNarrowWidth } = useResponsive(); + const highlightedIndexColor = isNarrowWidth + ? 'rgba(100, 100, 100, .15)' + : colors.n4; return ( <View> @@ -58,9 +67,33 @@ function CategoryList({ <View key="split" {...(getItemProps ? getItemProps({ item }) : null)} + // Downshift calls `setTimeout(..., 250)` in the `onMouseMove` + // event handler they set on this element. When this code runs + // in WebKit on touch-enabled devices, taps on this element end + // up not triggering the `onClick` event (and therefore delaying + // response to user input) until after the `setTimeout` callback + // finishes executing. This is caused by content observation code + // that implements various strategies to prevent the user from + // accidentally clicking content that changed as a result of code + // run in the `onMouseMove` event. + // + // Long story short, we don't want any delay here between the user + // tapping and the resulting action being performed. It turns out + // there's some "fast path" logic that can be triggered in various + // ways to force WebKit to bail on the content observation process. + // One of those ways is setting `role="button"` (or a number of + // other aria roles) on the element, which is what we're doing here. + // + // ref: + // * https://github.com/WebKit/WebKit/blob/447d90b0c52b2951a69df78f06bb5e6b10262f4b/LayoutTests/fast/events/touch/ios/content-observation/400ms-hover-intent.html + // * https://github.com/WebKit/WebKit/blob/58956cf59ba01267644b5e8fe766efa7aa6f0c5c/Source/WebCore/page/ios/ContentChangeObserver.cpp + // * https://github.com/WebKit/WebKit/blob/58956cf59ba01267644b5e8fe766efa7aa6f0c5c/Source/WebKit/WebProcess/WebPage/ios/WebPageIOS.mm#L783 + role="button" style={{ backgroundColor: - highlightedIndex === idx ? colors.n4 : 'transparent', + highlightedIndex === idx + ? highlightedIndexColor + : 'transparent', borderRadius: embedded ? 4 : 0, flexShrink: 0, flexDirection: 'row', @@ -69,6 +102,9 @@ function CategoryList({ fontWeight: 500, color: colors.g8, padding: '6px 8px', + ':active': { + backgroundColor: 'rgba(100, 100, 100, .25)', + }, }} data-testid="split-transaction-button" > @@ -89,6 +125,7 @@ function CategoryList({ style={{ color: colors.y9, padding: '4px 9px', + ...groupHeaderStyle, }} data-testid="category-item-group" > @@ -97,13 +134,22 @@ function CategoryList({ )} <div {...(getItemProps ? getItemProps({ item }) : null)} - style={{ - backgroundColor: - highlightedIndex === idx ? colors.n4 : 'transparent', - padding: 4, - paddingLeft: 20, - borderRadius: embedded ? 4 : 0, - }} + // See comment above. + role="button" + className={`${css([ + { + backgroundColor: + highlightedIndex === idx + ? highlightedIndexColor + : 'transparent', + padding: 4, + paddingLeft: 20, + borderRadius: embedded ? 4 : 0, + ':active': { + backgroundColor: 'rgba(100, 100, 100, .25)', + }, + }, + ])}`} data-testid={ 'category-item' + (highlightedIndex === idx ? '-highlighted' : '') @@ -123,11 +169,14 @@ function CategoryList({ type CategoryAutocompleteProps = ComponentProps<typeof Autocomplete> & { 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 11b73bb418a964d5cb638326fd63dc991317ddae..147be825bef3567633325070f262c6426f93cbd9 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)', + }, }} > <View style={{ display: 'block', - color: colors.g8, + color: createNewColor, borderRadius: 4, - fontSize: 11, + fontSize: isNarrowWidth ? 'inherit' : 11, fontWeight: 500, }} > @@ -123,6 +135,7 @@ function PayeeList({ style={{ color: colors.y9, padding: '4px 9px', + ...groupHeaderStyle, }} > {title} @@ -131,16 +144,43 @@ function PayeeList({ <div {...(getItemProps ? getItemProps({ item }) : null)} + // Downshift calls `setTimeout(..., 250)` in the `onMouseMove` + // event handler they set on this element. When this code runs + // in WebKit on touch-enabled devices, taps on this element end + // up not triggering the `onClick` event (and therefore delaying + // response to user input) until after the `setTimeout` callback + // finishes executing. This is caused by content observation code + // that implements various strategies to prevent the user from + // accidentally clicking content that changed as a result of code + // run in the `onMouseMove` event. + // + // Long story short, we don't want any delay here between the user + // tapping and the resulting action being performed. It turns out + // there's some "fast path" logic that can be triggered in various + // ways to force WebKit to bail on the content observation process. + // One of those ways is setting `role="button"` (or a number of + // other aria roles) on the element, which is what we're doing here. + // + // ref: + // * https://github.com/WebKit/WebKit/blob/447d90b0c52b2951a69df78f06bb5e6b10262f4b/LayoutTests/fast/events/touch/ios/content-observation/400ms-hover-intent.html + // * https://github.com/WebKit/WebKit/blob/58956cf59ba01267644b5e8fe766efa7aa6f0c5c/Source/WebCore/page/ios/ContentChangeObserver.cpp + // * https://github.com/WebKit/WebKit/blob/58956cf59ba01267644b5e8fe766efa7aa6f0c5c/Source/WebKit/WebProcess/WebPage/ios/WebPageIOS.mm#L783 + role="button" key={item.id} - style={{ - backgroundColor: - highlightedIndex === idx + offset - ? colors.n4 - : 'transparent', - borderRadius: embedded ? 4 : 0, - padding: 4, - paddingLeft: 20, - }} + className={`${css([ + { + backgroundColor: + highlightedIndex === idx + offset + ? highlightedIndexColor + : 'transparent', + borderRadius: embedded ? 4 : 0, + padding: 4, + paddingLeft: 20, + ':active': { + backgroundColor: 'rgba(100, 100, 100, .25)', + }, + }, + ])}`} > {item.name} </div> @@ -148,7 +188,7 @@ function PayeeList({ {showMoreMessage && ( <div style={{ - fontSize: 11, + fontSize: isNarrowWidth ? 'inherit' : 11, padding: 5, color: colors.n5, textAlign: 'center', @@ -174,9 +214,11 @@ export default function PayeeAutocomplete({ defaultFocusTransferPayees = false, tableBehavior, embedded, + closeOnBlur, onUpdate, onSelect, onManagePayees, + groupHeaderStyle, accounts, payees, ...props @@ -238,6 +280,7 @@ export default function PayeeAutocomplete({ value={stripNew(value)} suggestions={payeeSuggestions} tableBehavior={tableBehavior} + closeOnBlur={closeOnBlur} itemToString={item => { if (!item) { return ''; @@ -324,6 +367,7 @@ export default function PayeeAutocomplete({ highlightedIndex={highlightedIndex} inputValue={inputValue} embedded={embedded} + groupHeaderStyle={groupHeaderStyle} footer={ <AutocompleteFooter embedded={embedded}> {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 0000000000000000000000000000000000000000..b49065c9acc22ebd429c71d58b75f33607d39a47 --- /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 = ( + <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: 'white', + }, + style, + ]} + > + <View style={{ overflowY: 'auto' }}>{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 }); + } + } + + 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 ( + <View> + <AmountInput + {...this.props} + ref={el => (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]} + /> + + <View> + {!focused && ( + <Button + style={{ + position: 'absolute', + right: 'calc(100% + 5px)', + top: '8px', + }} + onClick={this.toggleIsNegative} + > + {this.state.isNegative ? '−' : '+'} + </Button> + )} + <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', + }, + }, + ]} + 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(this.props.value))} + </Text> + </View> + </Button> + </View> + </View> + ); + } +} 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 0000000000000000000000000000000000000000..67757db04362a0fc9691e8e07c703286998a197f --- /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 ( + <Text + style={[ + { + marginBottom: 5, + marginTop: flush ? 0 : 25, + fontSize: 13, + color: theme.tableRowHeaderText, + paddingLeft: EDITING_PADDING, + textTransform: 'uppercase', + userSelect: 'none', + }, + style, + ]} + > + {title} + </Text> + ); +} + +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 ( + <Input + ref={ref} + autoCorrect="false" + autoCapitalize="none" + disabled={disabled} + onBlur={e => { + onUpdate?.(e.target.value); + }} + style={[ + valueStyle, + style, + { + backgroundColor: disabled + ? theme.formInputTextReadOnlySelection + : 'white', + }, + ]} + {...props} + /> + ); +}); + +export function TapField({ + value, + children, + disabled, + rightContent, + style, + textStyle, + onClick, +}) { + return ( + <Button + as={View} + onClick={!disabled ? onClick : undefined} + style={[ + { flexDirection: 'row', alignItems: 'center' }, + style, + valueStyle, + { backgroundColor: 'white' }, + disabled && { backgroundColor: theme.formInputTextReadOnlySelection }, + ]} + bounce={false} + activeStyle={{ + opacity: 0.5, + boxShadow: 'none', + }} + hoveredStyle={{ + boxShadow: 'none', + }} + // activeOpacity={0.05} + > + {children ? ( + children + ) : ( + <Text style={[{ flex: 1, userSelect: 'none' }, textStyle]}> + {value} + </Text> + )} + {!disabled && rightContent} + </Button> + ); +} + +export function BooleanField({ checked, onUpdate, style }) { + return ( + <input + type="checkbox" + checked={checked} + onChange={e => 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 faff7a2612dcdd5f9704fbd6ae4f74ad969dab8a..f8eb44e466dc9370ebe010bc696f3bf5cf65c106 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 ( <Modal - noAnimation={true} - showHeader={false} + title={label} + noAnimation={!isNarrowWidth} + showHeader={isNarrowWidth} focusAfterClose={false} {...modalProps} padding={0} style={[ { flex: 0, + height: isNarrowWidth ? '85vh' : 275, padding: '15px 10px', - backgroundColor: colors.n1, - color: 'white', + borderRadius: '6px', }, minWidth && { minWidth }, + !isNarrowWidth && { backgroundColor: colors.n1, color: 'white' }, ]} > {() => ( <View> - <SectionLabel - title={label} - style={{ - alignSelf: 'center', - color: colors.b10, - marginBottom: 10, - }} - /> + {!isNarrowWidth && ( + <SectionLabel + title={label} + style={{ + alignSelf: 'center', + color: colors.b10, + marginBottom: 10, + }} + /> + )} <View style={{ flex: 1 }}>{editor}</View> </View> )} diff --git a/packages/desktop-client/src/components/transactions/MobileTransaction.js b/packages/desktop-client/src/components/transactions/MobileTransaction.js index bc7896786b774e5cd430e7c4212a0837796abee5..d5d3b66b6c21d525a0495050d0e9617448cf38e9 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 ( + // <KeyboardAvoidingView> + <View + style={{ + margin: 10, + marginTop: 3, + backgroundColor: colors.n11, + flex: 1, + borderRadius: 4, + + // This shadow make the card "pop" off of the screen below + // it + shadowColor: colors.n3, + shadowOffset: { width: 0, height: 0 }, + shadowRadius: 4, + shadowOpacity: 1, + }} + > + <View + style={{ + borderRadius: 4, + overflow: 'hidden', + display: 'flex', + flex: 'auto', + }} + > + <View + style={{ + borderBottomWidth: 1, + borderColor: colors.n9, + backgroundColor: 'white', + alignItems: 'center', + flexDirection: 'row', + flexShrink: 0, + justifyContent: 'space-between', + width: '100%', + padding: 10, + }} + > + <Link + to={`/accounts/${account.id}`} + style={{ + alignItems: 'center', + display: 'flex', + textDecoration: 'none', + width: LEFT_RIGHT_FLEX_WIDTH, + }} + > + <CheveronLeft + style={{ + color: colors.b5, + width: 32, + height: 32, + }} + /> + <Text + style={{ ...styles.text, color: colors.b5, fontWeight: 500 }} + > + Back + </Text> + </Link> + <TextOneLine + style={{ + color: theme.formInputText, + fontSize: 15, + fontWeight: 600, + userSelect: 'none', + }} + > + {payeeId == null + ? adding + ? 'New Transaction' + : 'Transaction' + : descriptionPretty} + </TextOneLine> + {/* For centering the transaction title */} + <View + style={{ + width: LEFT_RIGHT_FLEX_WIDTH, + }} + /> + </View> + + {/* <ScrollView + ref={el => (this.scrollView = el)} + automaticallyAdjustContentInsets={false} + keyboardShouldPersistTaps="always" + style={{ + backgroundColor: colors.n11, + flexGrow: 1, + overflow: 'hidden', + }} + contentContainerStyle={{ flexGrow: 1 }} + > */} + <View + style={{ + overflowY: 'auto', + overflowX: 'hidden', + display: 'block', + }} + > + <View + style={{ + alignItems: 'center', + marginTop: 20, + }} + > + <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> + + <View> + <FieldLabel title="Payee" /> + <TapField + value={descriptionPretty} + onClick={() => this.onClick(transaction.id, 'payee')} + /> + </View> + + <View> + <FieldLabel + title={ + transaction.is_parent ? 'Categories (split)' : 'Category' + } + /> + {!transaction.is_parent ? ( + <TapField + value={category ? lookupName(categories, category) : null} + disabled={(account && !!account.offbudget) || transferAcct} + // 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')} + /> + ) : ( + <Text style={{ paddingLeft: EDITING_PADDING }}> + Split transaction editing is not supported on mobile at this + time. + </Text> + )} + </View> + + <View> + <FieldLabel title="Account" /> + <TapField + disabled={!adding} + value={account ? account.name : null} + onClick={() => this.onClick(transaction.id, 'account')} + /> + </View> + + <View style={{ flexDirection: 'row' }}> + <View style={{ flex: 1 }}> + <FieldLabel title="Date" /> + <InputField + type="date" + required + style={{ color: 'canvastext', 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> + + <View style={{ marginLeft: 35, marginRight: 35 }}> + <FieldLabel title="Cleared" /> + <BooleanField + checked={transaction.cleared} + onUpdate={checked => + this.onEdit(transaction, 'cleared', checked) + } + style={{ marginTop: 4 }} + /> + </View> + </View> + + <View> + <FieldLabel title="Notes" /> + <InputField + defaultValue={transaction.notes} + onUpdate={value => this.onEdit(transaction, 'notes', value)} + onChange={e => + this.onQueueChange(transaction, 'notes', e.target.value) + } + /> + </View> + + {!adding && ( + <View style={{ alignItems: 'center' }}> + <Button + onClick={() => onDelete()} + style={{ + borderWidth: 0, + paddingVertical: 5, + marginLeft: EDITING_PADDING, + marginRight: EDITING_PADDING, + marginTop: 20, + marginBottom: 15, + backgroundColor: 'transparent', + }} + type="bare" + > + <SvgTrash + width={17} + height={17} + style={{ color: colors.r4 }} + /> + <Text + style={{ + color: colors.r4, + marginLeft: 5, + userSelect: 'none', + }} + > + Delete transaction + </Text> + </Button> + </View> + )} + </View> + + <View + style={{ + paddingLeft: EDITING_PADDING, + paddingRight: EDITING_PADDING, + paddingTop: 15, + paddingBottom: 15, + backgroundColor: colors.n11, + borderTopWidth: 1, + borderColor: colors.n10, + marginTop: 'auto', + flexShrink: 0, + }} + > + {adding ? ( + <Button onClick={() => this.onAdd()}> + <SvgAdd width={17} height={17} style={{ color: colors.b3 }} /> + <Text + style={[styles.text, { color: colors.b3, marginLeft: 5 }]} + > + Add transaction + </Text> + </Button> + ) : ( + <Button onClick={() => this.onSave()}> + <SvgPencilWriteAlternate + style={{ width: 16, height: 16, color: colors.n1 }} + /> + <Text + style={[styles.text, { marginLeft: 6, color: colors.n1 }]} + > + Save changes + </Text> + </Button> + )} + </View> + + {/* <ExitTransition + alive={editingChild} + withProps={{ + transaction: + editingChild && transactions.find(t => 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, + })} + {/* </ExitTransition> */} + </View> + </View> + // </KeyboardAvoidingView> + ); + } +} + +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 ( + <View + style={{ + flex: 1, + backgroundColor: colors.p5, + }} + > + <TransactionEditInner + transactions={transactions} + adding={adding} + categories={categories} + accounts={accounts} + payees={payees} + pushModal={props.pushModal} + navigation={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} + /> + </View> + ); +} + +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 ( - // <Button - // onClick={() => onSelect(transaction)} - // style={{ - // backgroundColor: 'white', - // border: 'none', - // width: '100%', - // '&:active': { opacity: 0.1 } - // }} - // > - <ListItem - style={[ - { flex: 1, height: 60, padding: '5px 10px' }, // remove padding when Button is back - isPreview && { backgroundColor: colors.n11 }, - style, - ]} + <Button + onClick={() => onSelect(transaction)} + style={{ + backgroundColor: 'white', + border: 'none', + width: '100%', + '&:active': { opacity: 0.1 }, + }} > - <View style={[{ flex: 1 }]}> - <View style={{ flexDirection: 'row', alignItems: 'center' }}> - {schedule && ( - <ArrowsSynchronize - style={{ - width: 12, - height: 12, - marginRight: 5, - color: textStyle.color || colors.n1, - }} - /> - )} - <TextOneLine - style={[ - styles.text, - textStyle, - { fontSize: 14, fontWeight: added ? '600' : '400' }, - prettyDescription === '' && { - color: colors.n6, - fontStyle: 'italic', - }, - ]} - > - {prettyDescription || 'Empty'} - </TextOneLine> - </View> - {isPreview ? ( - <Status status={notes} /> - ) : ( - <View - style={{ - flexDirection: 'row', - alignItems: 'center', - marginTop: 3, - }} - > - <CheckCircle1 - style={{ - width: 11, - height: 11, - color: cleared ? colors.g6 : colors.n8, - marginRight: 5, - }} - /> - {showCategory && ( - <TextOneLine + <ListItem + style={[ + { flex: 1, height: 60, padding: '5px 10px' }, // remove padding when Button is back + isPreview && { backgroundColor: colors.n11 }, + style, + ]} + > + <View style={[{ flex: 1 }]}> + <View style={{ flexDirection: 'row', alignItems: 'center' }}> + {schedule && ( + <ArrowsSynchronize style={{ - fontSize: 11, - marginTop: 1, - fontWeight: '400', - color: prettyCategory ? colors.n3 : colors.p7, - fontStyle: prettyCategory ? null : 'italic', - textAlign: 'left', + width: 12, + height: 12, + marginRight: 5, + color: textStyle.color || colors.n1, }} - > - {prettyCategory || 'Uncategorized'} - </TextOneLine> + /> )} + <TextOneLine + style={[ + styles.text, + textStyle, + { fontSize: 14, fontWeight: added ? '600' : '400' }, + prettyDescription === '' && { + color: colors.n6, + fontStyle: 'italic', + }, + ]} + > + {prettyDescription || 'Empty'} + </TextOneLine> </View> - )} - </View> - <Text - style={[ - styles.text, - textStyle, - { marginLeft: 25, marginRight: 5, fontSize: 14 }, - ]} - > - {integerToCurrency(amount)} - </Text> - </ListItem> - // </Button> + {isPreview ? ( + <Status status={notes} /> + ) : ( + <View + style={{ + flexDirection: 'row', + alignItems: 'center', + marginTop: 3, + }} + > + <CheckCircle1 + style={{ + width: 11, + height: 11, + color: cleared ? colors.g6 : colors.n8, + marginRight: 5, + }} + /> + {showCategory && ( + <TextOneLine + style={{ + fontSize: 11, + marginTop: 1, + fontWeight: '400', + color: prettyCategory ? colors.n3 : colors.p7, + 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> ); } } @@ -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)} /> </Item> ); diff --git a/packages/loot-core/src/shared/transactions.ts b/packages/loot-core/src/shared/transactions.ts index ac5675b5a8e754539f2166e112f27cb61fbf66d8..967532554b4ff799776346edf19a9ce1e81b31fd 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 0000000000000000000000000000000000000000..1eed73f323af1ee53643b5d5d29586a675ff1588 --- /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)