Compare commits

...

10 Commits

Author SHA1 Message Date
Joel Jeremy Marquez
d1cf1042af Fix payee 2025-06-16 16:03:55 -07:00
Joel Jeremy Marquez
f0f42044eb Fix getPrettyPayee 2025-06-16 15:51:04 -07:00
Dmitrijs Minajevs
fb9924aced apply suggestions 2025-06-16 15:32:26 -07:00
Dmitrijs Minajevs
da068173e4 merge 2025-06-16 15:32:08 -07:00
Dmitrijs Minajevs
eb84ddaff2 fix optional prop 2025-06-16 15:28:59 -07:00
Dmitrijs Minajevs
e970a2d11f remove controlled focus from FocusableAmountInput 2025-06-16 15:28:58 -07:00
Dmitrijs Minajevs
8438845e92 Remove single active form hook 2025-06-16 15:22:13 -07:00
Dmitrijs Minajevs
027aa00d0b Release note 2025-06-16 14:52:32 -07:00
Dmitrijs Minajevs
a978b686dc remove console.logs 2025-06-16 14:52:32 -07:00
Dmitrijs Minajevs
58d752e94c Fix mobile transaction form requires additional click to unfocus amount input 2025-06-16 14:52:32 -07:00
6 changed files with 125 additions and 358 deletions

View File

@@ -165,7 +165,7 @@ const AmountInput = memo(function AmountInput({
type FocusableAmountInputProps = Omit<AmountInputProps, 'onFocus'> & {
sign?: '+' | '-';
zeroSign?: '+' | '-';
focused?: boolean;
defaultFocused?: boolean;
disabled?: boolean;
focusedStyle?: CSSProperties;
buttonProps?: ComponentPropsWithRef<typeof Button>;
@@ -176,17 +176,20 @@ export const FocusableAmountInput = memo(function FocusableAmountInput({
value,
sign,
zeroSign,
focused,
defaultFocused,
disabled,
textStyle,
style,
focusedStyle,
buttonProps,
onFocus,
onBlur,
...props
}: FocusableAmountInputProps) {
const [isNegative, setIsNegative] = useState(true);
const [focused, setFocused] = useState(defaultFocused ?? false);
useEffect(() => {
setFocused(defaultFocused ?? false);
}, [defaultFocused]);
const maybeApplyNegative = (amount: number, negative: boolean) => {
const absValue = Math.abs(amount);
@@ -219,8 +222,8 @@ export const FocusableAmountInput = memo(function FocusableAmountInput({
<AmountInput
{...props}
value={value}
onFocus={onFocus}
onBlur={onBlur}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
onUpdateAmount={amount => onUpdateAmount(amount, isNegative)}
focused={focused && !disabled}
style={{
@@ -252,7 +255,7 @@ export const FocusableAmountInput = memo(function FocusableAmountInput({
</Button>
)}
<Button
onPress={onFocus}
onPress={() => setFocused(true)}
// Defines how far touch can start away from the button
// hitSlop={{ top: 5, bottom: 5, left: 5, right: 5 }}
{...buttonProps}

View File

@@ -68,22 +68,13 @@ import { AmountInput } from '@desktop-client/components/util/AmountInput';
import { useAccounts } from '@desktop-client/hooks/useAccounts';
import { useCategories } from '@desktop-client/hooks/useCategories';
import { useDateFormat } from '@desktop-client/hooks/useDateFormat';
import { useInitialMount } from '@desktop-client/hooks/useInitialMount';
import { useNavigate } from '@desktop-client/hooks/useNavigate';
import { usePayees } from '@desktop-client/hooks/usePayees';
import {
SingleActiveEditFormProvider,
useSingleActiveEditForm,
} from '@desktop-client/hooks/useSingleActiveEditForm';
import { pushModal } from '@desktop-client/modals/modalsSlice';
import { aqlQuery } from '@desktop-client/queries/aqlQuery';
import { setLastTransaction } from '@desktop-client/queries/queriesSlice';
import { useSelector, useDispatch } from '@desktop-client/redux';
function getFieldName(transactionId, field) {
return `${field}-${transactionId}`;
}
function serializeTransaction(transaction, dateFormat) {
const { date, amount } = transaction;
return {
@@ -174,7 +165,6 @@ function Footer({
onSplit,
onAddSplit,
onEmptySplitFound,
editingField,
onEditField,
}) {
const [transaction, ...childTransactions] = transactions;
@@ -208,7 +198,6 @@ function Footer({
<Button
variant="primary"
style={{ height: styles.mobileMinHeight }}
isDisabled={editingField}
onPress={onClickRemainingSplit}
>
<SvgSplit width={17} height={17} />
@@ -248,7 +237,6 @@ function Footer({
<Button
variant="primary"
style={{ height: styles.mobileMinHeight }}
isDisabled={editingField}
onPress={() => onEditField(transaction.id, 'account')}
>
<SvgPiggyBank width={17} height={17} />
@@ -265,7 +253,6 @@ function Footer({
<Button
variant="primary"
style={{ height: styles.mobileMinHeight }}
isDisabled={editingField}
onPress={onAdd}
>
<SvgAdd width={17} height={17} />
@@ -282,7 +269,6 @@ function Footer({
<Button
variant="primary"
style={{ height: styles.mobileMinHeight }}
isDisabled={editingField}
onPress={onSave}
>
<SvgPencilWriteAlternate width={16} height={16} />
@@ -318,8 +304,6 @@ const ChildTransactionEdit = forwardRef(
ref,
) => {
const { t } = useTranslation();
const { editingField, onRequestActiveEdit, onClearActiveEdit } =
useSingleActiveEditForm();
const prettyPayee = getPrettyPayee({
transaction,
payee: getPayee(transaction),
@@ -344,10 +328,6 @@ const ChildTransactionEdit = forwardRef(
<View style={{ flexBasis: '75%' }}>
<FieldLabel title={t('Payee')} />
<TapField
isDisabled={
editingField &&
editingField !== getFieldName(transaction.id, 'payee')
}
value={prettyPayee}
onPress={() => onEditField(transaction.id, 'payee')}
data-testid={`payee-field-${transaction.id}`}
@@ -360,10 +340,6 @@ const ChildTransactionEdit = forwardRef(
>
<FieldLabel title={t('Amount')} style={{ padding: 0 }} />
<AmountInput
disabled={
editingField &&
editingField !== getFieldName(transaction.id, 'amount')
}
focused={amountFocused}
value={amountToInteger(transaction.amount)}
zeroSign={amountSign}
@@ -373,15 +349,10 @@ const ChildTransactionEdit = forwardRef(
textAlign: 'right',
minWidth: 0,
}}
onFocus={() =>
onRequestActiveEdit(getFieldName(transaction.id, 'amount'))
}
onUpdate={value => {
const amount = integerToAmount(value);
if (transaction.amount !== amount) {
onUpdate(transaction, 'amount', amount);
} else {
onClearActiveEdit();
}
}}
autoDecimals={true}
@@ -400,12 +371,7 @@ const ChildTransactionEdit = forwardRef(
}),
}}
value={getCategory(transaction, isOffBudget)}
isDisabled={
(editingField &&
editingField !== getFieldName(transaction.id, 'category')) ||
isOffBudget ||
isBudgetTransfer(transaction)
}
isDisabled={isOffBudget || isBudgetTransfer(transaction)}
onPress={() => onEditField(transaction.id, 'category')}
data-testid={`category-field-${transaction.id}`}
/>
@@ -414,14 +380,7 @@ const ChildTransactionEdit = forwardRef(
<View>
<FieldLabel title={t('Notes')} />
<InputField
disabled={
editingField &&
editingField !== getFieldName(transaction.id, 'notes')
}
defaultValue={transaction.notes}
onFocus={() =>
onRequestActiveEdit(getFieldName(transaction.id, 'notes'))
}
onUpdate={value => onUpdate(transaction, 'notes', value)}
/>
</View>
@@ -495,34 +454,12 @@ const TransactionEditInner = memo(function TransactionEditInner({
const [transaction, ...childTransactions] = transactions;
const { editingField, onRequestActiveEdit, onClearActiveEdit } =
useSingleActiveEditForm();
const [totalAmountFocused, setTotalAmountFocused] = useState(
// iOS does not support automatically opening up the keyboard for the
// total amount field. Hence we should not focus on it on page render.
!Platform.isIOSAgent,
);
const childTransactionElementRefMap = useRef({});
const hasAccountChanged = useRef(false);
const payeesById = useMemo(() => groupById(payees), [payees]);
const accountsById = useMemo(() => groupById(accounts), [accounts]);
const onTotalAmountEdit = useCallback(() => {
onRequestActiveEdit?.(getFieldName(transaction.id, 'amount'), () => {
setTotalAmountFocused(true);
return () => setTotalAmountFocused(false);
});
}, [onRequestActiveEdit, transaction.id]);
const isInitialMount = useInitialMount();
useEffect(() => {
if (isInitialMount && isAdding && !Platform.isIOSAgent) {
onTotalAmountEdit();
}
}, [isAdding, isInitialMount, onTotalAmountEdit]);
const getAccount = useCallback(
trans => {
return trans?.account && accountsById?.[trans.account];
@@ -604,13 +541,12 @@ const TransactionEditInner = memo(function TransactionEditInner({
async (serializedTransaction, name, value) => {
const newTransaction = { ...serializedTransaction, [name]: value };
await onUpdate(newTransaction, name);
onClearActiveEdit();
if (name === 'account') {
hasAccountChanged.current = serializedTransaction.account !== value;
}
},
[onClearActiveEdit, onUpdate],
[onUpdate],
);
const onTotalAmountUpdate = useCallback(
@@ -622,103 +558,98 @@ const TransactionEditInner = memo(function TransactionEditInner({
[onUpdateInner, transaction],
);
const onEditFieldInner = useCallback(
const transactionsRef = useRef(transactions);
useEffect(() => {
transactionsRef.current = transactions;
}, [transactions]);
// getTransaction prevents stale-closure issue with dialogs
const getTransaction = useCallback(
transactionId => transactionsRef.current.find(t => t.id === transactionId),
[],
);
const onEditField = useCallback(
(transactionId, name) => {
onRequestActiveEdit?.(getFieldName(transaction.id, name), () => {
const transactionToEdit = transactions.find(
t => t.id === transactionId,
);
const unserializedTransaction = unserializedTransactions.find(
t => t.id === transactionId,
);
switch (name) {
case 'category':
dispatch(
pushModal({
modal: {
name: 'category-autocomplete',
options: {
categoryGroups,
month: monthUtils.monthFromDate(
unserializedTransaction.date,
),
onSelect: categoryId => {
onUpdateInner(transactionToEdit, name, categoryId);
},
onClose: () => {
onClearActiveEdit();
},
const unserializedTransaction = unserializedTransactions.find(
t => t.id === transactionId,
);
switch (name) {
case 'category':
dispatch(
pushModal({
modal: {
name: 'category-autocomplete',
options: {
categoryGroups,
month: monthUtils.monthFromDate(unserializedTransaction.date),
onSelect: categoryId => {
onUpdateInner(
getTransaction(transactionId),
name,
categoryId,
);
},
},
}),
);
break;
case 'account':
dispatch(
pushModal({
modal: {
name: 'account-autocomplete',
options: {
onSelect: accountId => {
onUpdateInner(transactionToEdit, name, accountId);
},
onClose: () => {
onClearActiveEdit();
},
},
}),
);
break;
case 'account':
dispatch(
pushModal({
modal: {
name: 'account-autocomplete',
options: {
onSelect: accountId => {
onUpdateInner(
getTransaction(transactionId),
name,
accountId,
);
},
},
}),
);
break;
case 'payee':
dispatch(
pushModal({
modal: {
name: 'payee-autocomplete',
options: {
onSelect: payeeId => {
onUpdateInner(transactionToEdit, name, payeeId);
},
onClose: () => {
onClearActiveEdit();
},
},
}),
);
break;
case 'payee':
dispatch(
pushModal({
modal: {
name: 'payee-autocomplete',
options: {
onSelect: payeeId => {
onUpdateInner(getTransaction(transactionId), name, payeeId);
},
},
}),
);
break;
default:
dispatch(
pushModal({
modal: {
name: 'edit-field',
options: {
name,
month: monthUtils.monthFromDate(
unserializedTransaction.date,
),
onSubmit: (name, value) => {
onUpdateInner(transactionToEdit, name, value);
},
onClose: () => {
onClearActiveEdit();
},
},
}),
);
break;
default:
dispatch(
pushModal({
modal: {
name: 'edit-field',
options: {
name,
month: monthUtils.monthFromDate(unserializedTransaction.date),
onSubmit: (name, value) => {
onUpdateInner(getTransaction(transactionId), name, value);
},
},
}),
);
break;
}
});
},
}),
);
break;
}
},
[
categoryGroups,
dispatch,
getTransaction,
onUpdateInner,
onClearActiveEdit,
onRequestActiveEdit,
transaction.id,
transactions,
unserializedTransactions,
],
);
@@ -738,7 +669,6 @@ const TransactionEditInner = memo(function TransactionEditInner({
if (unserializedTransaction.id !== id) {
// Only a child transaction was deleted.
onClearActiveEdit();
return;
}
@@ -766,7 +696,7 @@ const TransactionEditInner = memo(function TransactionEditInner({
onConfirmDelete();
}
},
[dispatch, navigate, onClearActiveEdit, onDelete, unserializedTransactions],
[dispatch, navigate, onDelete, unserializedTransactions],
);
const scrollChildTransactionIntoView = useCallback(id => {
@@ -835,8 +765,7 @@ const TransactionEditInner = memo(function TransactionEditInner({
onSplit={onSplit}
onAddSplit={onAddSplit}
onEmptySplitFound={onEmptySplitFound}
editingField={editingField}
onEditField={onEditFieldInner}
onEditField={onEditField}
/>
}
padding={0}
@@ -854,9 +783,7 @@ const TransactionEditInner = memo(function TransactionEditInner({
<FocusableAmountInput
value={transaction.amount}
zeroSign="-"
focused={totalAmountFocused}
onFocus={onTotalAmountEdit}
onBlur={onClearActiveEdit}
defaultFocused={!Platform.isIOSAgent}
onUpdateAmount={onTotalAmountUpdate}
focusedStyle={{
width: 'auto',
@@ -878,12 +805,12 @@ const TransactionEditInner = memo(function TransactionEditInner({
fontWeight: 300,
}),
}}
value={title}
isDisabled={
editingField &&
editingField !== getFieldName(transaction.id, 'payee')
}
onPress={() => onEditFieldInner(transaction.id, 'payee')}
value={getPrettyPayee({
transaction,
payee: getPayee(transaction),
transferAccount: getTransferAccount(transaction),
})}
onPress={() => onEditField(transaction.id, 'payee')}
data-testid="payee-field"
/>
</View>
@@ -900,13 +827,8 @@ const TransactionEditInner = memo(function TransactionEditInner({
}),
}}
value={getCategory(transaction, isOffBudget)}
isDisabled={
(editingField &&
editingField !== getFieldName(transaction.id, 'category')) ||
isOffBudget ||
isBudgetTransfer(transaction)
}
onPress={() => onEditFieldInner(transaction.id, 'category')}
isDisabled={isOffBudget || isBudgetTransfer(transaction)}
onPress={() => onEditField(transaction.id, 'category')}
data-testid="category-field"
/>
</View>
@@ -930,7 +852,7 @@ const TransactionEditInner = memo(function TransactionEditInner({
getTransferAccount={getTransferAccount}
isBudgetTransfer={isBudgetTransfer}
onUpdate={onUpdateInner}
onEditField={onEditFieldInner}
onEditField={onEditField}
onDelete={onDeleteInner}
/>
))}
@@ -939,7 +861,6 @@ const TransactionEditInner = memo(function TransactionEditInner({
<View style={{ alignItems: 'center' }}>
<Button
variant="bare"
isDisabled={editingField}
style={{
height: 40,
borderWidth: 0,
@@ -971,12 +892,8 @@ const TransactionEditInner = memo(function TransactionEditInner({
<View>
<FieldLabel title={t('Account')} />
<TapField
isDisabled={
editingField &&
editingField !== getFieldName(transaction.id, 'account')
}
value={account?.name}
onPress={() => onEditFieldInner(transaction.id, 'account')}
onPress={() => onEditField(transaction.id, 'account')}
data-testid="account-field"
/>
</View>
@@ -986,21 +903,14 @@ const TransactionEditInner = memo(function TransactionEditInner({
<FieldLabel title={t('Date')} />
<InputField
type="date"
disabled={
editingField &&
editingField !== getFieldName(transaction.id, 'date')
}
required
style={{ color: theme.tableText, minWidth: '150px' }}
defaultValue={dateDefaultValue}
onFocus={() =>
onRequestActiveEdit(getFieldName(transaction.id, 'date'))
}
onChange={event =>
onUpdateInner(
onUpdate={value =>
onUpdate(
transaction,
'date',
formatDate(parseISO(event.target.value), dateFormat),
formatDate(parseISO(value), dateFormat),
)
}
/>
@@ -1025,17 +935,8 @@ const TransactionEditInner = memo(function TransactionEditInner({
<View>
<FieldLabel title={t('Notes')} />
<InputField
disabled={
editingField &&
editingField !== getFieldName(transaction.id, 'notes')
}
defaultValue={transaction.notes}
onFocus={() => {
onRequestActiveEdit(getFieldName(transaction.id, 'notes'));
}}
onChange={event =>
onUpdateInner(transaction, 'notes', event.target.value)
}
onUpdate={value => onUpdateInner(transaction, 'notes', value)}
/>
</View>
@@ -1331,15 +1232,13 @@ export const TransactionEdit = props => {
const dateFormat = useDateFormat() || 'MM/dd/yyyy';
return (
<SingleActiveEditFormProvider formName="mobile-transaction">
<TransactionEditUnconnected
{...props}
categories={categories}
payees={payees}
lastTransaction={lastTransaction}
accounts={accounts}
dateFormat={dateFormat}
/>
</SingleActiveEditFormProvider>
<TransactionEditUnconnected
{...props}
categories={categories}
payees={payees}
lastTransaction={lastTransaction}
accounts={accounts}
dateFormat={dateFormat}
/>
);
};

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect, type CSSProperties } from 'react';
import React, { type CSSProperties } from 'react';
import { useTranslation } from 'react-i18next';
import { styles } from '@actual-app/components/styles';
@@ -46,20 +46,11 @@ export function EnvelopeBudgetMenuModal({
envelopeBudget.catBudgeted(categoryId),
);
const category = useCategory(categoryId);
const [amountFocused, setAmountFocused] = useState(false);
const _onUpdateBudget = (amount: number) => {
onUpdateBudget?.(amountToInteger(amount));
};
useEffect(() => {
// iOS does not support automatically opening up the keyboard for the
// total amount field. Hence we should not focus on it on page render.
if (!Platform.isIOSAgent) {
setAmountFocused(true);
}
}, []);
if (!category) {
return null;
}
@@ -89,11 +80,9 @@ export function EnvelopeBudgetMenuModal({
</Text>
<FocusableAmountInput
value={integerToAmount(budgeted || 0)}
focused={amountFocused}
onFocus={() => setAmountFocused(true)}
onBlur={() => setAmountFocused(false)}
onEnter={close}
zeroSign="+"
defaultFocused={!Platform.isIOSAgent}
focusedStyle={{
width: 'auto',
padding: '5px',

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect, type CSSProperties } from 'react';
import React, { type CSSProperties } from 'react';
import { useTranslation } from 'react-i18next';
import { styles } from '@actual-app/components/styles';
@@ -46,20 +46,11 @@ export function TrackingBudgetMenuModal({
trackingBudget.catBudgeted(categoryId),
);
const category = useCategory(categoryId);
const [amountFocused, setAmountFocused] = useState(false);
const _onUpdateBudget = (amount: number) => {
onUpdateBudget?.(amountToInteger(amount));
};
useEffect(() => {
// iOS does not support automatically opening up the keyboard for the
// total amount field. Hence we should not focus on it on page render.
if (!Platform.isIOSAgent) {
setAmountFocused(true);
}
}, []);
if (!category) {
return null;
}
@@ -89,11 +80,9 @@ export function TrackingBudgetMenuModal({
</Text>
<FocusableAmountInput
value={integerToAmount(budgeted || 0)}
focused={amountFocused}
onFocus={() => setAmountFocused(true)}
onBlur={() => setAmountFocused(false)}
onEnter={close}
zeroSign="+"
defaultFocused={!Platform.isIOSAgent}
focusedStyle={{
width: 'auto',
padding: '5px',

View File

@@ -1,119 +0,0 @@
// @ts-strict-ignore
import React, {
type ReactNode,
createContext,
useContext,
useState,
useRef,
} from 'react';
type ActiveEditCleanup = () => void;
type ActiveEditAction = () => void | ActiveEditCleanup;
type SingleActiveEditFormContextValue = {
formName: string;
editingField: string;
onRequestActiveEdit: (
field: string,
action?: ActiveEditAction,
options?: {
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 cleanupRef = useRef<ActiveEditCleanup | void>(null);
const runCleanup = () => {
const editCleanup = cleanupRef.current;
if (typeof editCleanup === 'function') {
editCleanup?.();
}
cleanupRef.current = null;
};
const runAction = (action: ActiveEditAction) => {
cleanupRef.current = action?.();
};
const onClearActiveEdit = (delayMs?: number) => {
setTimeout(() => {
runCleanup();
setEditingField(null);
}, delayMs);
};
const onActiveEdit = (field: string, action: ActiveEditAction) => {
runAction(action);
setEditingField(field);
};
const onRequestActiveEdit = (
field: string,
action: ActiveEditAction,
options: {
clearActiveEditDelayMs?: number;
},
) => {
if (editingField === field) {
// Already active.
return;
}
if (editingField) {
onClearActiveEdit(options?.clearActiveEditDelayMs);
} else {
onActiveEdit(field, action);
}
};
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,
};
}

View File

@@ -0,0 +1,6 @@
---
category: Bugfix
authors: [minajevs]
---
Fix mobile transaction form requires additional click to unfocus amount input