Add find and replace (with RegEx support) mode when editing transaction notes (#6282)

* prevent hidden toggle input from taking space

* add find and replace to edit notes modal

* make mode buttons same height

* add release note

* fix PR number

* Update VRT screenshots

Auto-generated by VRT workflow

PR: #6282

* use checkbox instead of mobile toggle

* typo

* Update VRT screenshots

Auto-generated by VRT workflow

PR: #6282

* fix import

* review

* update

* update

* update

* update

* fix mistaken deletion

* Update VRT screenshots

Auto-generated by VRT workflow

PR: #6282

* Update VRT screenshots

Auto-generated by VRT workflow

PR: #6282

* Reorder note amendment strings in EditFieldModal

* require find value

* update mode order

* retrigger checks

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: youngcw <calebyoung94@gmail.com>
This commit is contained in:
Matthew Mao
2026-01-05 09:25:43 -08:00
committed by GitHub
parent 52559eb221
commit d9c759ff1b
20 changed files with 223 additions and 141 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.6 KiB

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.6 KiB

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.9 KiB

After

Width:  |  Height:  |  Size: 8.5 KiB

View File

@@ -1,11 +1,11 @@
import React, {
import {
forwardRef,
useEffect,
useState,
useRef,
memo,
useMemo,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { useLocation, useParams, useSearchParams } from 'react-router';
@@ -25,9 +25,9 @@ import { Toggle } from '@actual-app/components/toggle';
import { View } from '@actual-app/components/view';
import {
format as formatDate,
isValid as isValidDate,
parse as parseDate,
parseISO,
isValid as isValidDate,
} from 'date-fns';
import { send } from 'loot-core/platform/client/fetch';
@@ -36,22 +36,23 @@ import * as Platform from 'loot-core/shared/platform';
import { q } from 'loot-core/shared/query';
import { getStatusLabel, getUpcomingDays } from 'loot-core/shared/schedules';
import {
ungroupTransactions,
updateTransaction,
realizeTempTransactions,
splitTransaction,
addSplitTransaction,
deleteTransaction,
makeChild,
realizeTempTransactions,
splitTransaction,
ungroupTransactions,
updateTransaction,
} from 'loot-core/shared/transactions';
import {
titleFirst,
integerToCurrency,
integerToAmount,
amountToInteger,
getChangedValues,
applyFindReplace,
diffItems,
getChangedValues,
groupById,
integerToAmount,
integerToCurrency,
titleFirst,
} from 'loot-core/shared/util';
import {
type AccountEntity,
@@ -65,8 +66,8 @@ import { FocusableAmountInput } from './FocusableAmountInput';
import { MobileBackButton } from '@desktop-client/components/mobile/MobileBackButton';
import {
FieldLabel,
TapField,
InputField,
TapField,
ToggleField,
} from '@desktop-client/components/mobile/MobileForms';
import { getPrettyPayee } from '@desktop-client/components/mobile/utils';
@@ -88,7 +89,7 @@ import { useSyncedPref } from '@desktop-client/hooks/useSyncedPref';
import { pushModal } from '@desktop-client/modals/modalsSlice';
import { addNotification } from '@desktop-client/notifications/notificationsSlice';
import { aqlQuery } from '@desktop-client/queries/aqlQuery';
import { useSelector, useDispatch } from '@desktop-client/redux';
import { useDispatch, useSelector } from '@desktop-client/redux';
import { setLastTransaction } from '@desktop-client/transactions/transactionsSlice';
function getFieldName(transactionId: TransactionEntity['id'], field: string) {
@@ -884,7 +885,20 @@ const TransactionEditInner = memo<TransactionEditInnerProps>(
options: {
name,
onSubmit: (name, value) => {
onUpdateInner(transactionToEdit, name, value);
if (typeof value === 'object' && 'useRegex' in value) {
onUpdateInner(
transactionToEdit,
name,
applyFindReplace(
transactionToEdit.notes,
value.find,
value.replace,
value.useRegex,
),
);
} else {
onUpdateInner(transactionToEdit, name, value);
}
},
onClose: () => {
onClearActiveEdit();

View File

@@ -1,17 +1,12 @@
import React, {
type CSSProperties,
type ReactNode,
useRef,
useState,
} from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { type CSSProperties, type ReactNode, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@actual-app/components/button';
import { useResponsive } from '@actual-app/components/hooks/useResponsive';
import { Input } from '@actual-app/components/input';
import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import { parseISO, format as formatDate, parse as parseDate } from 'date-fns';
import { format as formatDate, parse as parseDate, parseISO } from 'date-fns';
import { currentDay, dayFromDate } from 'loot-core/shared/months';
import { amountToInteger, currencyToInteger } from 'loot-core/shared/util';
@@ -22,6 +17,7 @@ import {
ModalHeader,
} from '@desktop-client/components/common/Modal';
import { SectionLabel } from '@desktop-client/components/forms';
import { LabeledCheckbox } from '@desktop-client/components/forms/LabeledCheckbox';
import { DateSelect } from '@desktop-client/components/select/DateSelect';
import { useDateFormat } from '@desktop-client/hooks/useDateFormat';
import { type Modal as ModalType } from '@desktop-client/modals/modalsSlice';
@@ -33,7 +29,16 @@ const itemStyle: CSSProperties = {
paddingBottom: 8,
};
type NoteAmendMode = 'replace' | 'prepend' | 'append';
type NoteAmendValue = Parameters<EditFieldModalProps['onSubmit']>[1];
type NoteAmendMode = NonNullable<
Parameters<EditFieldModalProps['onSubmit']>[2]
>;
const noteAmendStrings: Record<NoteAmendMode, string> = {
prepend: 'Prepend',
replace: 'Replace',
append: 'Append',
findAndReplace: 'Find and Replace',
};
type EditFieldModalProps = Extract<
ModalType,
@@ -48,8 +53,9 @@ export function EditFieldModal({
const { t } = useTranslation();
const dateFormat = useDateFormat() || 'MM/dd/yyyy';
const noteInputRef = useRef<HTMLInputElement | null>(null);
const noteReplaceInputRef = useRef<HTMLInputElement | null>(null);
function onSelectNote(value: string, mode?: NoteAmendMode) {
function onSelectNote(value: NoteAmendValue, mode?: NoteAmendMode) {
if (value != null) {
onSubmit(name, value, mode);
}
@@ -85,6 +91,13 @@ export function EditFieldModal({
};
const [noteAmend, onChangeMode] = useState<NoteAmendMode>('replace');
const [noteFindReplace, setNoteFindReplace] = useState<
Extract<NoteAmendValue, { useRegex: boolean }>
>({
useRegex: false,
find: '',
replace: '',
});
switch (name) {
case 'date':
@@ -118,111 +131,111 @@ export function EditFieldModal({
marginRight: 4,
alignItems: 'center',
justifyContent: 'center',
gap: 5,
}}
>
<Button
style={{
padding: '5px 10px',
width: '33.33%',
backgroundColor: theme.menuBackground,
marginRight: 5,
fontSize: 'inherit',
...(noteAmend === 'prepend' && {
backgroundColor: theme.buttonPrimaryBackground,
color: theme.buttonPrimaryText,
':hover': {
backgroundColor: theme.buttonPrimaryBackgroundHover,
color: theme.buttonPrimaryTextHover,
},
}),
...(noteAmend !== 'prepend' && {
backgroundColor: theme.buttonNormalBackground,
color: theme.buttonNormalText,
':hover': {
backgroundColor: theme.buttonNormalBackgroundHover,
color: theme.buttonNormalTextHover,
},
}),
}}
onPress={() => {
onChangeMode('prepend');
noteInputRef.current?.focus();
}}
>
<Trans>Prepend</Trans>
</Button>
<Button
style={{
padding: '5px 10px',
width: '33.34%',
backgroundColor: theme.menuBackground,
marginRight: 5,
fontSize: 'inherit',
...(noteAmend === 'replace' && {
backgroundColor: theme.buttonPrimaryBackground,
color: theme.buttonPrimaryText,
':hover': {
backgroundColor: theme.buttonPrimaryBackgroundHover,
color: theme.buttonPrimaryTextHover,
},
}),
...(noteAmend !== 'replace' && {
backgroundColor: theme.buttonNormalBackground,
color: theme.buttonNormalText,
':hover': {
backgroundColor: theme.buttonNormalBackgroundHover,
color: theme.buttonNormalTextHover,
},
}),
}}
onPress={() => {
onChangeMode('replace');
noteInputRef.current?.focus();
}}
>
<Trans>Replace</Trans>
</Button>
<Button
style={{
padding: '5px 10px',
width: '33.33%',
backgroundColor: theme.menuBackground,
marginRight: 5,
fontSize: 'inherit',
...(noteAmend === 'append' && {
backgroundColor: theme.buttonPrimaryBackground,
color: theme.buttonPrimaryText,
':hover': {
backgroundColor: theme.buttonPrimaryBackgroundHover,
color: theme.buttonPrimaryTextHover,
},
}),
...(noteAmend !== 'append' && {
backgroundColor: theme.buttonNormalBackground,
color: theme.buttonNormalText,
':hover': {
backgroundColor: theme.buttonNormalBackgroundHover,
color: theme.buttonNormalTextHover,
},
}),
}}
onPress={() => {
onChangeMode('append');
noteInputRef.current?.focus();
}}
>
<Trans>Append</Trans>
</Button>
{Object.keys(noteAmendStrings).map((mode, _, arr) => (
<Button
key={mode}
style={{
padding: '5px 10px',
height: '100%',
width: `${100 / arr.length}%`,
backgroundColor: theme.menuBackground,
fontSize: 'inherit',
...(noteAmend === mode && {
backgroundColor: theme.buttonPrimaryBackground,
color: theme.buttonPrimaryText,
':hover': {
backgroundColor: theme.buttonPrimaryBackgroundHover,
color: theme.buttonPrimaryTextHover,
},
}),
...(noteAmend !== mode && {
backgroundColor: theme.buttonNormalBackground,
color: theme.buttonNormalText,
':hover': {
backgroundColor: theme.buttonNormalBackgroundHover,
color: theme.buttonNormalTextHover,
},
}),
}}
onPress={() => {
onChangeMode(mode as NoteAmendMode);
noteInputRef.current?.focus();
}}
>
{t(noteAmendStrings[mode as NoteAmendMode])}
</Button>
))}
</View>
<Input
ref={noteInputRef}
autoFocus
onEnter={value => {
onSelectNote(value, noteAmend);
close();
}}
style={inputStyle}
/>
{noteAmend === 'findAndReplace' ? (
<View style={{ gap: 10 }}>
<LabeledCheckbox
id="noteRegex"
checked={noteFindReplace.useRegex}
onChange={({ currentTarget: { checked } }) =>
setNoteFindReplace(current => ({
...current,
useRegex: checked,
}))
}
>
{t('Use Regular Expressions')}
</LabeledCheckbox>
<Input
ref={noteInputRef}
autoFocus
placeholder={t('Find')}
value={noteFindReplace.find}
onChange={({ currentTarget: { value } }) =>
setNoteFindReplace(current => ({ ...current, find: value }))
}
onEnter={() => {
noteReplaceInputRef.current?.focus();
}}
style={inputStyle}
/>
<Input
ref={noteReplaceInputRef}
placeholder={t('Replace')}
value={noteFindReplace.replace}
onChange={({ currentTarget: { value } }) =>
setNoteFindReplace(current => ({
...current,
replace: value,
}))
}
onEnter={() => {
if (noteFindReplace.find === '') {
alert(t('Find value required'));
return;
}
if (noteFindReplace.useRegex) {
try {
new RegExp(noteFindReplace.find, 'g');
} catch {
alert(t('Invalid regular expression'));
return;
}
}
onSelectNote(noteFindReplace, noteAmend);
close();
}}
style={inputStyle}
/>
</View>
) : (
<Input
ref={noteInputRef}
autoFocus
onEnter={value => {
onSelectNote(value, noteAmend);
close();
}}
style={inputStyle}
/>
)}
</>
);
break;

View File

@@ -11,15 +11,22 @@ import {
updateTransaction,
} from 'loot-core/shared/transactions';
import { validForTransfer } from 'loot-core/shared/transfer';
import { applyChanges, type Diff } from 'loot-core/shared/util';
import {
type PayeeEntity,
applyChanges,
applyFindReplace,
type Diff,
} from 'loot-core/shared/util';
import {
type AccountEntity,
type PayeeEntity,
type ScheduleEntity,
type TransactionEntity,
} from 'loot-core/types/models';
import { pushModal } from '@desktop-client/modals/modalsSlice';
import {
pushModal,
type Modal as ModalType,
} from '@desktop-client/modals/modalsSlice';
import { aqlQuery } from '@desktop-client/queries/aqlQuery';
import { useDispatch } from '@desktop-client/redux';
@@ -29,8 +36,15 @@ type BatchEditProps = {
onSuccess?: (
ids: Array<TransactionEntity['id']>,
name: keyof TransactionEntity,
value: string | number | boolean | null,
mode: 'prepend' | 'append' | 'replace' | null | undefined,
value:
| Parameters<
Extract<ModalType, { name: 'edit-field' }>['options']['onSubmit']
>[1]
| boolean
| null,
mode: Parameters<
Extract<ModalType, { name: 'edit-field' }>['options']['onSubmit']
>[2],
) => void;
};
@@ -73,8 +87,8 @@ export function useTransactionBatchActions() {
const onChange = async (
name: keyof TransactionEntity,
value: string | number | boolean | null,
mode?: 'prepend' | 'append' | 'replace' | null | undefined,
value: Parameters<NonNullable<BatchEditProps['onSuccess']>>[2],
mode?: Parameters<NonNullable<BatchEditProps['onSuccess']>>[3],
) => {
let transactionsToChange = transactions;
@@ -117,6 +131,17 @@ export function useTransactionBatchActions() {
trans.notes === null ? value : `${trans.notes}${value}`;
} else if (mode === 'replace') {
valueToSet = value;
} else if (
mode === 'findAndReplace' &&
typeof value === 'object' &&
'useRegex' in value
) {
valueToSet = applyFindReplace(
trans.notes,
value.find,
value.replace,
value.useRegex,
);
}
}
const transaction = {

View File

@@ -9,13 +9,13 @@ import {
type CategoryGroupEntity,
type GoCardlessToken,
type NewRuleEntity,
type NewUserEntity,
type NoteEntity,
type RuleEntity,
type ScheduleEntity,
type TransactionEntity,
type UserEntity,
type UserAccessEntity,
type NewUserEntity,
type NoteEntity,
type UserEntity,
} from 'loot-core/types/models';
import { type Template } from 'loot-core/types/models/templates';
@@ -203,8 +203,15 @@ export type Modal =
name: keyof Pick<TransactionEntity, 'date' | 'amount' | 'notes'>;
onSubmit: (
name: keyof Pick<TransactionEntity, 'date' | 'amount' | 'notes'>,
value: string | number,
mode?: 'prepend' | 'append' | 'replace' | null,
value:
| string
| number
| {
useRegex: boolean;
find: string;
replace: string;
},
mode?: 'prepend' | 'append' | 'replace' | 'findAndReplace' | null,
) => void;
onClose?: () => void;
};

View File

@@ -603,3 +603,20 @@ export function tsToRelativeTime(
return distance;
}
export function applyFindReplace(
text: string | null | undefined,
find: string,
replace: string,
useRegex: boolean,
): string {
if (find === '') return text ?? '';
if (!text) return '';
try {
const pattern = useRegex ? new RegExp(find, 'g') : find;
return text.replaceAll(pattern, replace);
} catch {
return text;
}
}

View File

@@ -0,0 +1,6 @@
---
category: Enhancements
authors: [matt8100]
---
Add find and replace (with RegEx support) mode when editing transaction notes