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>
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 8.6 KiB After Width: | Height: | Size: 8.3 KiB |
|
Before Width: | Height: | Size: 8.6 KiB After Width: | Height: | Size: 8.3 KiB |
|
Before Width: | Height: | Size: 8.9 KiB After Width: | Height: | Size: 8.5 KiB |
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
6
upcoming-release-notes/6282.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Enhancements
|
||||
authors: [matt8100]
|
||||
---
|
||||
|
||||
Add find and replace (with RegEx support) mode when editing transaction notes
|
||||