Compare commits

...

2 Commits

Author SHA1 Message Date
Joel Jeremy Marquez
e40dbf2201 Remove unnecessary subcomponents 2024-11-26 01:27:29 -08:00
Joel Jeremy Marquez
509a77c22f Use useTransactions is TransactionEdit 2024-11-26 00:06:51 -08:00

View File

@@ -3,7 +3,6 @@ import React, {
useEffect, useEffect,
useState, useState,
useRef, useRef,
memo,
useMemo, useMemo,
useCallback, useCallback,
} from 'react'; } from 'react';
@@ -19,12 +18,11 @@ import {
import { t } from 'i18next'; import { t } from 'i18next';
import { pushModal, setLastTransaction } from 'loot-core/client/actions'; import { pushModal, setLastTransaction } from 'loot-core/client/actions';
import { runQuery } from 'loot-core/src/client/query-helpers'; import { useTransactions } from 'loot-core/client/data-hooks/transactions';
import { send } from 'loot-core/src/platform/client/fetch'; import { send } from 'loot-core/src/platform/client/fetch';
import * as monthUtils from 'loot-core/src/shared/months'; import * as monthUtils from 'loot-core/src/shared/months';
import { q } from 'loot-core/src/shared/query'; import { q } from 'loot-core/src/shared/query';
import { import {
ungroupTransactions,
updateTransaction, updateTransaction,
realizeTempTransactions, realizeTempTransactions,
splitTransaction, splitTransaction,
@@ -52,6 +50,7 @@ import {
SingleActiveEditFormProvider, SingleActiveEditFormProvider,
useSingleActiveEditForm, useSingleActiveEditForm,
} from '../../../hooks/useSingleActiveEditForm'; } from '../../../hooks/useSingleActiveEditForm';
import { AnimatedLoading } from '../../../icons/AnimatedLoading';
import { SvgSplit } from '../../../icons/v0'; import { SvgSplit } from '../../../icons/v0';
import { SvgAdd, SvgPiggyBank, SvgTrash } from '../../../icons/v1'; import { SvgAdd, SvgPiggyBank, SvgTrash } from '../../../icons/v1';
import { SvgPencilWriteAlternate } from '../../../icons/v2'; import { SvgPencilWriteAlternate } from '../../../icons/v2';
@@ -156,7 +155,7 @@ export function Status({ status, isSplit }) {
function Footer({ function Footer({
transactions, transactions,
adding, isAdding,
onAdd, onAdd,
onSave, onSave,
onSplit, onSplit,
@@ -232,7 +231,7 @@ function Footer({
Select account Select account
</Text> </Text>
</Button> </Button>
) : adding ? ( ) : isAdding ? (
<Button <Button
type="primary" type="primary"
style={{ height: styles.mobileMinHeight }} style={{ height: styles.mobileMinHeight }}
@@ -435,30 +434,188 @@ const ChildTransactionEdit = forwardRef(
ChildTransactionEdit.displayName = 'ChildTransactionEdit'; ChildTransactionEdit.displayName = 'ChildTransactionEdit';
const TransactionEditInner = memo(function TransactionEditInner({ function isTemporary(transaction) {
adding, return transaction.id.indexOf('temp') === 0;
accounts, }
categories,
payees, function makeTemporaryTransactions(accountId, categoryId, lastDate) {
dateFormat, return [
transactions: unserializedTransactions, {
onSave, id: 'temp',
onUpdate, date: lastDate || monthUtils.currentDay(),
onDelete, account: accountId,
onSplit, category: categoryId,
onAddSplit, amount: 0,
}) { cleared: false,
},
];
}
function TransactionEditInner() {
const { list: categories } = useCategories();
const payees = usePayees();
const lastTransaction = useSelector(state => state.queries.lastTransaction);
const accounts = useAccounts();
const dateFormat = useDateFormat() || 'MM/dd/yyyy';
const { transactionId } = useParams();
const { state: locationState } = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const dispatch = useDispatch(); const dispatch = useDispatch();
const transactions = useMemo( const isDeletedRef = useRef(false);
const isAddingRef = useRef(false);
isAddingRef.current = transactionId === 'new';
const transactionQuery = useMemo(
() => () =>
unserializedTransactions.map(t => serializeTransaction(t, dateFormat)) || transactionId !== null
[], ? q('transactions')
[unserializedTransactions, dateFormat], .filter({ id: transactionId })
.select('*')
.options({ splits: 'all' })
: null,
[transactionId],
);
const { transactions: queriedTransactions, isLoading } = useTransactions({
query: transactionQuery,
});
const [transactions, setTransactions] = useState(
makeTemporaryTransactions(
locationState?.accountId || lastTransaction?.account || null,
locationState?.categoryId || null,
lastTransaction?.date,
),
);
useEffect(() => {
if (queriedTransactions?.length > 0) {
setTransactions(queriedTransactions);
}
}, [queriedTransactions]);
const onUpdate = useCallback(
async (transaction, updatedField) => {
// Run the rules to auto-fill in any data. Right now we only do
// this on new transactions because that's how desktop works.
const newTransaction = { ...transaction };
if (isTemporary(newTransaction)) {
const afterRules = await send('rules-run', {
transaction: newTransaction,
});
const diff = getChangedValues(newTransaction, afterRules);
if (diff) {
Object.keys(diff).forEach(field => {
if (
newTransaction[field] == null ||
newTransaction[field] === '' ||
newTransaction[field] === 0 ||
newTransaction[field] === false
) {
newTransaction[field] = diff[field];
}
});
// When a rule updates a parent transaction, overwrite all changes to the current field in subtransactions.
if (
newTransaction.is_parent &&
diff.subtransactions !== undefined &&
updatedField !== null
) {
newTransaction.subtransactions = diff.subtransactions.map(
(st, idx) => ({
...(newTransaction.subtransactions[idx] || st),
...(st[updatedField] != null && {
[updatedField]: st[updatedField],
}),
}),
);
}
}
}
const changes = updateTransaction(transactions, newTransaction);
setTransactions(changes.data);
},
[transactions],
);
const onSave = useCallback(
async newTransactions => {
if (isDeletedRef.current) {
return;
}
const changes = diffItems(queriedTransactions || [], transactions);
if (
changes.added.length > 0 ||
changes.updated.length > 0 ||
changes.deleted.length > 0
) {
await send('transactions-batch-update', {
added: changes.added,
deleted: changes.deleted,
updated: changes.updated,
});
}
if (isAddingRef.current) {
// The first one is always the "parent" and the only one we care
// about
dispatch(setLastTransaction(newTransactions[0]));
}
},
[dispatch, transactions, queriedTransactions],
);
const onDelete = useCallback(
async id => {
const changes = deleteTransaction(transactions, id);
if (isAddingRef.current) {
// Adding a new transactions, this disables saving when the component unmounts
isDeletedRef.current = true;
} else {
await send('transactions-batch-update', {
deleted: changes.diff.deleted,
});
}
setTransactions(changes.data);
},
[transactions],
);
const onAddSplit = useCallback(
id => {
const changes = addSplitTransaction(transactions, id);
setTransactions(changes.data);
},
[transactions],
);
const onSplit = useCallback(
id => {
const changes = splitTransaction(transactions, id, parent => [
makeChild(parent),
makeChild(parent),
]);
setTransactions(changes.data);
},
[transactions],
);
const serializedTransactions = useMemo(
() => transactions.map(t => serializeTransaction(t, dateFormat)) || [],
[transactions, dateFormat],
); );
const { grouped: categoryGroups } = useCategories(); const { grouped: categoryGroups } = useCategories();
const [transaction, ...childTransactions] = transactions; const [serializedTransaction, ...serializedChildTransactions] =
serializedTransactions;
const { editingField, onRequestActiveEdit, onClearActiveEdit } = const { editingField, onRequestActiveEdit, onClearActiveEdit } =
useSingleActiveEditForm(); useSingleActiveEditForm();
@@ -470,19 +627,22 @@ const TransactionEditInner = memo(function TransactionEditInner({
const accountsById = useMemo(() => groupById(accounts), [accounts]); const accountsById = useMemo(() => groupById(accounts), [accounts]);
const onTotalAmountEdit = useCallback(() => { const onTotalAmountEdit = useCallback(() => {
onRequestActiveEdit?.(getFieldName(transaction.id, 'amount'), () => { onRequestActiveEdit?.(
getFieldName(serializedTransaction.id, 'amount'),
() => {
setTotalAmountFocused(true); setTotalAmountFocused(true);
return () => setTotalAmountFocused(false); return () => setTotalAmountFocused(false);
}); },
}, [onRequestActiveEdit, transaction.id]); );
}, [onRequestActiveEdit, serializedTransaction.id]);
const isInitialMount = useInitialMount(); const isInitialMount = useInitialMount();
useEffect(() => { useEffect(() => {
if (isInitialMount && adding) { if (isInitialMount && isAddingRef.current) {
onTotalAmountEdit(); onTotalAmountEdit();
} }
}, [adding, isInitialMount, onTotalAmountEdit]); }, [isInitialMount, onTotalAmountEdit]);
const getAccount = useCallback( const getAccount = useCallback(
trans => { trans => {
@@ -528,18 +688,20 @@ const TransactionEditInner = memo(function TransactionEditInner({
); );
const onSaveInner = useCallback(() => { const onSaveInner = useCallback(() => {
const [unserializedTransaction] = unserializedTransactions; const [transaction] = transactions;
const onConfirmSave = () => { const onConfirmSave = () => {
let transactionsToSave = unserializedTransactions; let transactionsToSave = transactions;
if (adding) { if (isAddingRef.current) {
transactionsToSave = realizeTempTransactions(unserializedTransactions); transactionsToSave = realizeTempTransactions(transactions);
} }
onSave(transactionsToSave); onSave(transactionsToSave);
if (adding || hasAccountChanged.current) { const isAddingFromAccountPage =
const { account: accountId } = unserializedTransaction; isAddingRef.current && locationState?.accountId;
if (!isAddingFromAccountPage || hasAccountChanged.current) {
const { account: accountId } = transaction;
const account = accountsById?.[accountId]; const account = accountsById?.[accountId];
if (account) { if (account) {
navigate(`/accounts/${account.id}`, { replace: true }); navigate(`/accounts/${account.id}`, { replace: true });
@@ -552,7 +714,7 @@ const TransactionEditInner = memo(function TransactionEditInner({
} }
}; };
if (unserializedTransaction.reconciled) { if (transaction.reconciled) {
// On mobile any save gives the warning. // On mobile any save gives the warning.
// On the web only certain changes trigger a 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 // Should we bring that here as well? Or does the nature of the editing form
@@ -568,54 +730,63 @@ const TransactionEditInner = memo(function TransactionEditInner({
} }
}, [ }, [
accountsById, accountsById,
adding,
dispatch, dispatch,
locationState?.accountId,
navigate, navigate,
onSave, onSave,
unserializedTransactions, transactions,
]); ]);
const onUpdateInner = useCallback( const onUpdateInner = useCallback(
async (serializedTransaction, name, value) => { async (serializedTransaction, name, value) => {
const newTransaction = { ...serializedTransaction, [name]: value }; const newTransaction = { ...serializedTransaction, [name]: value };
await onUpdate(newTransaction, name); const transaction = deserializeTransaction(
newTransaction,
null,
dateFormat,
);
await onUpdate(transaction, name);
onClearActiveEdit(); onClearActiveEdit();
if (name === 'account') { if (name === 'account') {
hasAccountChanged.current = serializedTransaction.account !== value; hasAccountChanged.current = transaction.account !== value;
} }
}, },
[onClearActiveEdit, onUpdate], [dateFormat, onClearActiveEdit, onUpdate],
); );
const onTotalAmountUpdate = useCallback( const onTotalAmountUpdate = useCallback(
value => { value => {
if (transaction.amount !== value) { if (serializedTransaction.amount !== value) {
onUpdateInner(transaction, 'amount', value.toString()); onUpdateInner(serializedTransaction, 'amount', value.toString());
} else { } else {
onClearActiveEdit(); onClearActiveEdit();
} }
}, },
[onClearActiveEdit, onUpdateInner, transaction], [onClearActiveEdit, onUpdateInner, serializedTransaction],
); );
const onEditFieldInner = useCallback( const onEditFieldInner = useCallback(
(transactionId, name) => { (transactionId, name) => {
onRequestActiveEdit?.(getFieldName(transaction.id, name), () => { onRequestActiveEdit?.(
const transactionToEdit = transactions.find( getFieldName(serializedTransaction.id, name),
t => t.id === transactionId, () => {
); const serializedTransactionToEdit = serializedTransactions.find(
const unserializedTransaction = unserializedTransactions.find(
t => t.id === transactionId, t => t.id === transactionId,
); );
const transaction = transactions.find(t => t.id === transactionId);
switch (name) { switch (name) {
case 'category': case 'category':
dispatch( dispatch(
pushModal('category-autocomplete', { pushModal('category-autocomplete', {
categoryGroups, categoryGroups,
month: monthUtils.monthFromDate(unserializedTransaction.date), month: monthUtils.monthFromDate(transaction.date),
onSelect: categoryId => { onSelect: categoryId => {
onUpdateInner(transactionToEdit, name, categoryId); onUpdateInner(
serializedTransactionToEdit,
name,
categoryId,
);
}, },
onClose: () => { onClose: () => {
onClearActiveEdit(); onClearActiveEdit();
@@ -627,7 +798,7 @@ const TransactionEditInner = memo(function TransactionEditInner({
dispatch( dispatch(
pushModal('account-autocomplete', { pushModal('account-autocomplete', {
onSelect: accountId => { onSelect: accountId => {
onUpdateInner(transactionToEdit, name, accountId); onUpdateInner(serializedTransactionToEdit, name, accountId);
}, },
onClose: () => { onClose: () => {
onClearActiveEdit(); onClearActiveEdit();
@@ -639,7 +810,7 @@ const TransactionEditInner = memo(function TransactionEditInner({
dispatch( dispatch(
pushModal('payee-autocomplete', { pushModal('payee-autocomplete', {
onSelect: payeeId => { onSelect: payeeId => {
onUpdateInner(transactionToEdit, name, payeeId); onUpdateInner(serializedTransactionToEdit, name, payeeId);
}, },
onClose: () => { onClose: () => {
onClearActiveEdit(); onClearActiveEdit();
@@ -651,9 +822,9 @@ const TransactionEditInner = memo(function TransactionEditInner({
dispatch( dispatch(
pushModal('edit-field', { pushModal('edit-field', {
name, name,
month: monthUtils.monthFromDate(unserializedTransaction.date), month: monthUtils.monthFromDate(transaction.date),
onSubmit: (name, value) => { onSubmit: (name, value) => {
onUpdateInner(transactionToEdit, name, value); onUpdateInner(serializedTransactionToEdit, name, value);
}, },
onClose: () => { onClose: () => {
onClearActiveEdit(); onClearActiveEdit();
@@ -662,23 +833,24 @@ const TransactionEditInner = memo(function TransactionEditInner({
); );
break; break;
} }
}); },
);
}, },
[ [
categoryGroups, categoryGroups,
dispatch, dispatch,
onUpdateInner,
onClearActiveEdit, onClearActiveEdit,
onRequestActiveEdit, onRequestActiveEdit,
transaction.id, onUpdateInner,
serializedTransaction.id,
serializedTransactions,
transactions, transactions,
unserializedTransactions,
], ],
); );
const onDeleteInner = useCallback( const onDeleteInner = useCallback(
id => { id => {
const [unserializedTransaction] = unserializedTransactions; const [transaction] = transactions;
const onConfirmDelete = () => { const onConfirmDelete = () => {
dispatch( dispatch(
@@ -686,7 +858,7 @@ const TransactionEditInner = memo(function TransactionEditInner({
onConfirm: () => { onConfirm: () => {
onDelete(id); onDelete(id);
if (unserializedTransaction.id !== id) { if (transaction.id !== id) {
// Only a child transaction was deleted. // Only a child transaction was deleted.
onClearActiveEdit(); onClearActiveEdit();
return; return;
@@ -698,7 +870,7 @@ const TransactionEditInner = memo(function TransactionEditInner({
); );
}; };
if (unserializedTransaction.reconciled) { if (transaction.reconciled) {
dispatch( dispatch(
pushModal('confirm-transaction-edit', { pushModal('confirm-transaction-edit', {
onConfirm: onConfirmDelete, onConfirm: onConfirmDelete,
@@ -709,7 +881,7 @@ const TransactionEditInner = memo(function TransactionEditInner({
onConfirmDelete(); onConfirmDelete();
} }
}, },
[dispatch, navigate, onClearActiveEdit, onDelete, unserializedTransactions], [dispatch, navigate, onClearActiveEdit, onDelete, transactions],
); );
const scrollChildTransactionIntoView = useCallback(id => { const scrollChildTransactionIntoView = useCallback(id => {
@@ -728,36 +900,60 @@ const TransactionEditInner = memo(function TransactionEditInner({
); );
useEffect(() => { useEffect(() => {
const noAmountChildTransaction = childTransactions.find( const noAmountChildTransaction = serializedChildTransactions.find(
t => t.amount === 0, t => t.amount === 0,
); );
if (noAmountChildTransaction) { if (noAmountChildTransaction) {
scrollChildTransactionIntoView(noAmountChildTransaction.id); scrollChildTransactionIntoView(noAmountChildTransaction.id);
} }
}, [childTransactions, scrollChildTransactionIntoView]); }, [serializedChildTransactions, scrollChildTransactionIntoView]);
// Child transactions should always default to the signage // Child transactions should always default to the signage
// of the parent transaction // of the parent transaction
const childAmountSign = transaction.amount <= 0 ? '-' : '+'; const childAmountSign = serializedTransaction.amount <= 0 ? '-' : '+';
const account = getAccount(transaction); const account = getAccount(serializedTransaction);
const isOffBudget = account && !!account.offbudget; const isOffBudget = account && !!account.offbudget;
const title = getPrettyPayee({ const title = getPrettyPayee({
transaction, transaction: serializedTransaction,
payee: getPayee(transaction), payee: getPayee(serializedTransaction),
transferAccount: getTransferAccount(transaction), transferAccount: getTransferAccount(serializedTransaction),
}); });
const transactionDate = parseDate(transaction.date, dateFormat, new Date()); const transactionDate = parseDate(
serializedTransaction.date,
dateFormat,
new Date(),
);
const dateDefaultValue = monthUtils.dayFromDate(transactionDate); const dateDefaultValue = monthUtils.dayFromDate(transactionDate);
if (
isLoading ||
categories.length === 0 ||
accounts.length === 0 ||
transactions.length === 0
) {
return (
<View
aria-label={t('Loading...')}
style={{
flex: 1,
justifyContent: 'center',
alignItems: 'center',
}}
>
<AnimatedLoading width={25} height={25} />
</View>
);
}
return ( return (
<Page <Page
header={ header={
<MobilePageHeader <MobilePageHeader
title={ title={
transaction.payee == null serializedTransaction.payee == null
? adding ? isAddingRef.current
? 'New Transaction' ? 'New Transaction'
: 'Transaction' : 'Transaction'
: title : title
@@ -772,7 +968,7 @@ const TransactionEditInner = memo(function TransactionEditInner({
footer={ footer={
<Footer <Footer
transactions={transactions} transactions={transactions}
adding={adding} isAdding={isAddingRef.current}
onAdd={onSaveInner} onAdd={onSaveInner}
onSave={onSaveInner} onSave={onSaveInner}
onSplit={onSplit} onSplit={onSplit}
@@ -792,7 +988,7 @@ const TransactionEditInner = memo(function TransactionEditInner({
> >
<FieldLabel title={t('Amount')} flush style={{ marginBottom: 0 }} /> <FieldLabel title={t('Amount')} flush style={{ marginBottom: 0 }} />
<FocusableAmountInput <FocusableAmountInput
value={transaction.amount} value={serializedTransaction.amount}
zeroSign="-" zeroSign="-"
focused={totalAmountFocused} focused={totalAmountFocused}
onFocus={onTotalAmountEdit} onFocus={onTotalAmountEdit}
@@ -812,7 +1008,7 @@ const TransactionEditInner = memo(function TransactionEditInner({
<FieldLabel title={t('Payee')} /> <FieldLabel title={t('Payee')} />
<TapField <TapField
textStyle={{ textStyle={{
...(transaction.is_parent && { ...(serializedTransaction.is_parent && {
fontStyle: 'italic', fontStyle: 'italic',
fontWeight: 300, fontWeight: 300,
}), }),
@@ -820,38 +1016,42 @@ const TransactionEditInner = memo(function TransactionEditInner({
value={title} value={title}
disabled={ disabled={
editingField && editingField &&
editingField !== getFieldName(transaction.id, 'payee') editingField !== getFieldName(serializedTransaction.id, 'payee')
} }
onClick={() => onEditFieldInner(transaction.id, 'payee')} onClick={() => onEditFieldInner(serializedTransaction.id, 'payee')}
data-testid="payee-field" data-testid="payee-field"
/> />
</View> </View>
{!transaction.is_parent && ( {!serializedTransaction.is_parent && (
<View> <View>
<FieldLabel title={t('Category')} /> <FieldLabel title={t('Category')} />
<TapField <TapField
style={{ style={{
...((isOffBudget || isBudgetTransfer(transaction)) && { ...((isOffBudget ||
isBudgetTransfer(serializedTransaction)) && {
fontStyle: 'italic', fontStyle: 'italic',
color: theme.pageTextSubdued, color: theme.pageTextSubdued,
fontWeight: 300, fontWeight: 300,
}), }),
}} }}
value={getCategory(transaction, isOffBudget)} value={getCategory(serializedTransaction, isOffBudget)}
disabled={ disabled={
(editingField && (editingField &&
editingField !== getFieldName(transaction.id, 'category')) || editingField !==
getFieldName(serializedTransaction.id, 'category')) ||
isOffBudget || isOffBudget ||
isBudgetTransfer(transaction) isBudgetTransfer(serializedTransaction)
}
onClick={() =>
onEditFieldInner(serializedTransaction.id, 'category')
} }
onClick={() => onEditFieldInner(transaction.id, 'category')}
data-testid="category-field" data-testid="category-field"
/> />
</View> </View>
)} )}
{childTransactions.map((childTrans, i, arr) => ( {serializedChildTransactions.map((childTrans, i, arr) => (
<ChildTransactionEdit <ChildTransactionEdit
key={childTrans.id} key={childTrans.id}
transaction={childTrans} transaction={childTrans}
@@ -874,7 +1074,8 @@ const TransactionEditInner = memo(function TransactionEditInner({
/> />
))} ))}
{transaction.amount !== 0 && childTransactions.length === 0 && ( {serializedTransaction.amount !== 0 &&
serializedChildTransactions.length === 0 && (
<View style={{ alignItems: 'center' }}> <View style={{ alignItems: 'center' }}>
<Button <Button
disabled={editingField} disabled={editingField}
@@ -886,7 +1087,7 @@ const TransactionEditInner = memo(function TransactionEditInner({
marginTop: 10, marginTop: 10,
backgroundColor: 'transparent', backgroundColor: 'transparent',
}} }}
onClick={() => onSplit(transaction.id)} onClick={() => onSplit(serializedTransaction.id)}
type="bare" type="bare"
> >
<SvgSplit <SvgSplit
@@ -912,10 +1113,12 @@ const TransactionEditInner = memo(function TransactionEditInner({
<TapField <TapField
disabled={ disabled={
editingField && editingField &&
editingField !== getFieldName(transaction.id, 'account') editingField !== getFieldName(serializedTransaction.id, 'account')
} }
value={account?.name} value={account?.name}
onClick={() => onEditFieldInner(transaction.id, 'account')} onClick={() =>
onEditFieldInner(serializedTransaction.id, 'account')
}
data-testid="account-field" data-testid="account-field"
/> />
</View> </View>
@@ -927,24 +1130,26 @@ const TransactionEditInner = memo(function TransactionEditInner({
type="date" type="date"
disabled={ disabled={
editingField && editingField &&
editingField !== getFieldName(transaction.id, 'date') editingField !== getFieldName(serializedTransaction.id, 'date')
} }
required required
style={{ color: theme.tableText, minWidth: '150px' }} style={{ color: theme.tableText, minWidth: '150px' }}
defaultValue={dateDefaultValue} defaultValue={dateDefaultValue}
onFocus={() => onFocus={() =>
onRequestActiveEdit(getFieldName(transaction.id, 'date')) onRequestActiveEdit(
getFieldName(serializedTransaction.id, 'date'),
)
} }
onUpdate={value => onUpdate={value =>
onUpdateInner( onUpdateInner(
transaction, serializedTransaction,
'date', 'date',
formatDate(parseISO(value), dateFormat), formatDate(parseISO(value), dateFormat),
) )
} }
/> />
</View> </View>
{transaction.reconciled ? ( {serializedTransaction.reconciled ? (
<View style={{ alignItems: 'center' }}> <View style={{ alignItems: 'center' }}>
<FieldLabel title={t('Reconciled')} /> <FieldLabel title={t('Reconciled')} />
<Toggle id="Reconciled" isOn isDisabled /> <Toggle id="Reconciled" isOn isDisabled />
@@ -954,8 +1159,10 @@ const TransactionEditInner = memo(function TransactionEditInner({
<FieldLabel title={t('Cleared')} /> <FieldLabel title={t('Cleared')} />
<ToggleField <ToggleField
id="cleared" id="cleared"
isOn={transaction.cleared} isOn={serializedTransaction.cleared}
onToggle={on => onUpdateInner(transaction, 'cleared', on)} onToggle={on =>
onUpdateInner(serializedTransaction, 'cleared', on)
}
/> />
</View> </View>
)} )}
@@ -966,20 +1173,24 @@ const TransactionEditInner = memo(function TransactionEditInner({
<InputField <InputField
disabled={ disabled={
editingField && editingField &&
editingField !== getFieldName(transaction.id, 'notes') editingField !== getFieldName(serializedTransaction.id, 'notes')
} }
defaultValue={transaction.notes} defaultValue={serializedTransaction.notes}
onFocus={() => { onFocus={() => {
onRequestActiveEdit(getFieldName(transaction.id, 'notes')); onRequestActiveEdit(
getFieldName(serializedTransaction.id, 'notes'),
);
}} }}
onUpdate={value => onUpdateInner(transaction, 'notes', value)} onUpdate={value =>
onUpdateInner(serializedTransaction, 'notes', value)
}
/> />
</View> </View>
{!adding && ( {!isAddingRef.current && (
<View style={{ alignItems: 'center' }}> <View style={{ alignItems: 'center' }}>
<Button <Button
onClick={() => onDeleteInner(transaction.id)} onClick={() => onDeleteInner(serializedTransaction.id)}
style={{ style={{
height: 40, height: 40,
borderWidth: 0, borderWidth: 0,
@@ -1010,273 +1221,12 @@ const TransactionEditInner = memo(function TransactionEditInner({
</View> </View>
</Page> </Page>
); );
});
function isTemporary(transaction) {
return transaction.id.indexOf('temp') === 0;
} }
function makeTemporaryTransactions(accountId, categoryId, lastDate) { export function TransactionEdit() {
return [
{
id: 'temp',
date: lastDate || monthUtils.currentDay(),
account: accountId,
category: categoryId,
amount: 0,
cleared: false,
},
];
}
function TransactionEditUnconnected({
categories,
accounts,
payees,
lastTransaction,
dateFormat,
}) {
const { transactionId } = useParams();
const { state: locationState } = useLocation();
const navigate = useNavigate();
const dispatch = useDispatch();
const [transactions, setTransactions] = useState([]);
const [fetchedTransactions, setFetchedTransactions] = useState([]);
const adding = useRef(false);
const deleted = useRef(false);
useEffect(() => {
let unmounted = false;
async function fetchTransaction() {
// 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' }),
);
if (!unmounted) {
const fetchedTransactions = ungroupTransactions(data);
setTransactions(fetchedTransactions);
setFetchedTransactions(fetchedTransactions);
}
}
if (transactionId !== 'new') {
fetchTransaction();
} else {
adding.current = true;
}
return () => {
unmounted = true;
};
}, [transactionId]);
useEffect(() => {
if (adding.current) {
setTransactions(
makeTemporaryTransactions(
locationState?.accountId || lastTransaction?.account || null,
locationState?.categoryId || null,
lastTransaction?.date,
),
);
}
}, [locationState?.accountId, locationState?.categoryId, lastTransaction]);
const onUpdate = useCallback(
async (serializedTransaction, updatedField) => {
const transaction = deserializeTransaction(
serializedTransaction,
null,
dateFormat,
);
// Run the rules to auto-fill in any data. Right now we only do
// this on new transactions because that's how desktop works.
const newTransaction = { ...transaction };
if (isTemporary(newTransaction)) {
const afterRules = await send('rules-run', {
transaction: newTransaction,
});
const diff = getChangedValues(newTransaction, afterRules);
if (diff) {
Object.keys(diff).forEach(field => {
if (
newTransaction[field] == null ||
newTransaction[field] === '' ||
newTransaction[field] === 0 ||
newTransaction[field] === false
) {
newTransaction[field] = diff[field];
}
});
// When a rule updates a parent transaction, overwrite all changes to the current field in subtransactions.
if (
newTransaction.is_parent &&
diff.subtransactions !== undefined &&
updatedField !== null
) {
newTransaction.subtransactions = diff.subtransactions.map(
(st, idx) => ({
...(newTransaction.subtransactions[idx] || st),
...(st[updatedField] != null && {
[updatedField]: st[updatedField],
}),
}),
);
}
}
}
const { data: newTransactions } = updateTransaction(
transactions,
newTransaction,
);
setTransactions(newTransactions);
},
[dateFormat, transactions],
);
const onSave = useCallback(
async newTransactions => {
if (deleted.current) {
return;
}
const changes = diffItems(fetchedTransactions || [], 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.current) {
// The first one is always the "parent" and the only one we care
// about
dispatch(setLastTransaction(newTransactions[0]));
}
},
[dispatch, fetchedTransactions],
);
const onDelete = useCallback(
async id => {
const changes = deleteTransaction(transactions, id);
if (adding.current) {
// Adding a new transactions, this disables saving when the component unmounts
deleted.current = true;
} else {
const _remoteUpdates = await send('transactions-batch-update', {
deleted: changes.diff.deleted,
});
// if (onTransactionsChange) {
// onTransactionsChange({ ...changes, updated: remoteUpdates });
// }
}
setTransactions(changes.data);
},
[transactions],
);
const onAddSplit = useCallback(
id => {
const changes = addSplitTransaction(transactions, id);
setTransactions(changes.data);
},
[transactions],
);
const onSplit = useCallback(
id => {
const changes = splitTransaction(transactions, id, parent => [
makeChild(parent),
makeChild(parent),
]);
setTransactions(changes.data);
},
[transactions],
);
if (
categories.length === 0 ||
accounts.length === 0 ||
transactions.length === 0
) {
return null;
}
return (
<View
style={{
flex: 1,
backgroundColor: theme.pageBackground,
}}
>
<TransactionEditInner
transactions={transactions}
adding={adding.current}
categories={categories}
accounts={accounts}
payees={payees}
navigate={navigate}
dateFormat={dateFormat}
onUpdate={onUpdate}
onSave={onSave}
onDelete={onDelete}
onSplit={onSplit}
onAddSplit={onAddSplit}
/>
</View>
);
}
export const TransactionEdit = props => {
const { list: categories } = useCategories();
const payees = usePayees();
const lastTransaction = useSelector(state => state.queries.lastTransaction);
const accounts = useAccounts();
const dateFormat = useDateFormat() || 'MM/dd/yyyy';
return ( return (
<SingleActiveEditFormProvider formName="mobile-transaction"> <SingleActiveEditFormProvider formName="mobile-transaction">
<TransactionEditUnconnected <TransactionEditInner />
{...props}
categories={categories}
payees={payees}
lastTransaction={lastTransaction}
accounts={accounts}
dateFormat={dateFormat}
/>
</SingleActiveEditFormProvider> </SingleActiveEditFormProvider>
); );
}; }