Compare commits

...

6 Commits

Author SHA1 Message Date
Joel Jeremy Marquez
8c190dc480 Coderabbit feedback 2026-03-03 17:20:36 +00:00
Joel Jeremy Marquez
b288ce5708 Code review 2026-02-24 22:21:59 +00:00
Joel Jeremy Marquez
8630a4fda6 Fix lint errors 2026-02-24 22:05:29 +00:00
github-actions[bot]
2cc9daf50a Add release notes for PR #7070 2026-02-24 22:04:24 +00:00
Joel Jeremy Marquez
fbc1025c2b React Query - create new queries and mutations for rules 2026-02-24 21:46:53 +00:00
Juulz
a1e0b3f45d Rename theme 'Okabe Ito' to 'Color-blind (dark)' (#7058)
* Rename theme 'Okabe Ito' to 'Color-blind (dark)'

* Rename 'Okabe Ito' theme to 'Color-blind (dark)'

* Fix capitalization in theme name for consistency
2026-02-23 18:49:45 +00:00
30 changed files with 1030 additions and 540 deletions

View File

@@ -1,16 +1,16 @@
// @ts-strict-ignore
import React, { useEffect, useEffectEvent, useMemo, useState } from 'react';
import type { Dispatch, SetStateAction } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { Button } from '@actual-app/components/button';
import { AnimatedLoading } from '@actual-app/components/icons/AnimatedLoading';
import { SpaceBetween } from '@actual-app/components/space-between';
import { styles } from '@actual-app/components/styles';
import { Text } from '@actual-app/components/text';
import { theme } from '@actual-app/components/theme';
import { Tooltip } from '@actual-app/components/tooltip';
import { View } from '@actual-app/components/view';
import { send } from 'loot-core/platform/client/connection';
import * as undo from 'loot-core/platform/client/undo';
import { getNormalisedString } from 'loot-core/shared/normalisation';
import { q } from 'loot-core/shared/query';
@@ -30,7 +30,9 @@ import { RulesList } from './rules/RulesList';
import { useAccounts } from '@desktop-client/hooks/useAccounts';
import { useCategories } from '@desktop-client/hooks/useCategories';
import { usePayeeRules } from '@desktop-client/hooks/usePayeeRules';
import { usePayees } from '@desktop-client/hooks/usePayees';
import { useRules } from '@desktop-client/hooks/useRules';
import { useSchedules } from '@desktop-client/hooks/useSchedules';
import {
SelectedProvider,
@@ -38,6 +40,10 @@ import {
} from '@desktop-client/hooks/useSelected';
import { pushModal } from '@desktop-client/modals/modalsSlice';
import { useDispatch } from '@desktop-client/redux';
import {
useBatchDeleteRulesMutation,
useDeleteRuleMutation,
} from '@desktop-client/rules';
export type FilterData = {
payees?: Array<{ id: string; name: string }>;
@@ -115,17 +121,36 @@ export function ruleToString(rule: RuleEntity, data: FilterData) {
type ManageRulesProps = {
isModal: boolean;
payeeId: string | null;
setLoading?: Dispatch<SetStateAction<boolean>>;
};
export function ManageRules({
isModal,
payeeId,
setLoading = () => {},
}: ManageRulesProps) {
export function ManageRules({ isModal, payeeId }: ManageRulesProps) {
const { t } = useTranslation();
const [allRules, setAllRules] = useState<RuleEntity[]>([]);
const {
data: allRules = [],
refetch: refetchAllRules,
isLoading: isAllRulesLoading,
isRefetching: isAllRulesRefetching,
} = useRules({
enabled: !payeeId,
});
const {
data: payeeRules = [],
refetch: refetchPayeeRules,
isLoading: isPayeeRulesLoading,
isRefetching: isPayeeRulesRefetching,
} = usePayeeRules({
payeeId,
});
const rulesToUse = payeeId ? payeeRules : allRules;
const refetchRules = payeeId ? refetchPayeeRules : refetchAllRules;
const isLoading =
isAllRulesLoading ||
isAllRulesRefetching ||
isPayeeRulesLoading ||
isPayeeRulesRefetching;
const [page, setPage] = useState(0);
const [filter, setFilter] = useState('');
const dispatch = useDispatch();
@@ -147,7 +172,7 @@ export function ManageRules({
);
const filteredRules = useMemo(() => {
const rules = allRules.filter(rule => {
const rules = rulesToUse.filter(rule => {
const schedule = schedules.find(schedule => schedule.rule === rule.id);
return schedule ? schedule.completed === false : true;
});
@@ -161,7 +186,7 @@ export function ManageRules({
),
)
).slice(0, 100 + page * 50);
}, [allRules, filter, filterData, page, schedules]);
}, [rulesToUse, filter, filterData, page, schedules]);
const selectedInst = useSelected('manage-rules', filteredRules, []);
const [hoveredRule, setHoveredRule] = useState(null);
@@ -171,38 +196,16 @@ export function ManageRules({
setPage(0);
};
async function loadRules() {
setLoading(true);
let loadedRules = null;
if (payeeId) {
loadedRules = await send('payees-get-rules', {
id: payeeId,
});
} else {
loadedRules = await send('rules-get');
}
setAllRules(loadedRules);
return loadedRules;
}
const init = useEffectEvent(() => {
async function loadData() {
await loadRules();
setLoading(false);
}
if (payeeId) {
undo.setUndoState('openModal', { name: 'manage-rules', options: {} });
}
void loadData();
return () => {
undo.setUndoState('openModal', null);
};
});
useEffect(() => {
return init();
}, []);
@@ -211,29 +214,33 @@ export function ManageRules({
setPage(page => page + 1);
}
const { mutate: batchDeleteRules } = useBatchDeleteRulesMutation();
const onDeleteSelected = async () => {
setLoading(true);
const { someDeletionsFailed } = await send('rule-delete-all', [
...selectedInst.items,
]);
if (someDeletionsFailed) {
alert(
t('Some rules were not deleted because they are linked to schedules.'),
);
}
await loadRules();
selectedInst.dispatch({ type: 'select-none' });
setLoading(false);
batchDeleteRules(
{
ids: [...selectedInst.items],
},
{
onSuccess: () => {
void refetchRules();
selectedInst.dispatch({ type: 'select-none' });
},
},
);
};
async function onDeleteRule(id: string) {
setLoading(true);
await send('rule-delete', id);
await loadRules();
setLoading(false);
const { mutate: deleteRule } = useDeleteRuleMutation();
function onDeleteRule(id: string) {
deleteRule(
{ id },
{
onSuccess: () => {
void refetchRules();
},
},
);
}
const onEditRule = rule => {
@@ -244,8 +251,7 @@ export function ManageRules({
options: {
rule,
onSave: async () => {
await loadRules();
setLoading(false);
void refetchRules();
},
},
},
@@ -282,8 +288,7 @@ export function ManageRules({
options: {
rule,
onSave: async () => {
await loadRules();
setLoading(false);
void refetchRules();
},
},
},
@@ -295,6 +300,24 @@ export function ManageRules({
setHoveredRule(id);
};
if (isLoading) {
return (
<View
style={{
flex: 1,
alignItems: 'center',
justifyContent: 'center',
}}
>
<AnimatedLoading width={25} height={25} />
</View>
);
}
const isNonDeletableRuleSelected = schedules.some(schedule =>
selectedInst.items.has(schedule.rule),
);
return (
<SelectedProvider instance={selectedInst}>
<View>
@@ -361,11 +384,24 @@ export function ManageRules({
>
<SpaceBetween gap={10} style={{ justifyContent: 'flex-end' }}>
{selectedInst.items.size > 0 && (
<Button onPress={onDeleteSelected}>
<Trans count={selectedInst.items.size}>
Delete {{ count: selectedInst.items.size }} rules
</Trans>
</Button>
<Tooltip
isOpen={isNonDeletableRuleSelected}
content={
<Trans>
Some selected rules cannot be deleted because they are
linked to schedules.
</Trans>
}
>
<Button
onPress={onDeleteSelected}
isDisabled={isNonDeletableRuleSelected}
>
<Trans count={selectedInst.items.size}>
Delete {{ count: selectedInst.items.size }} rules
</Trans>
</Button>
</Tooltip>
)}
<Button variant="primary" onPress={onCreateRule}>
<Trans>Create new rule</Trans>

View File

@@ -83,6 +83,7 @@ import { pagedQuery } from '@desktop-client/queries/pagedQuery';
import type { PagedQuery } from '@desktop-client/queries/pagedQuery';
import { useDispatch, useSelector } from '@desktop-client/redux';
import type { AppDispatch } from '@desktop-client/redux/store';
import { useRunRulesMutation } from '@desktop-client/rules/mutations';
import { updateNewTransactions } from '@desktop-client/transactions/transactionsSlice';
type ConditionEntity = Partial<RuleConditionEntity> | TransactionFilterEntity;
@@ -251,6 +252,7 @@ type AccountInternalProps = {
onUnlinkAccount: (id: AccountEntity['id']) => void;
onSyncAndDownload: (accountId?: AccountEntity['id']) => void;
onCreatePayee: (name: PayeeEntity['name']) => Promise<PayeeEntity['id']>;
onRunRules: (transaction: TransactionEntity) => Promise<TransactionEntity>;
};
type AccountInternalState = {
@@ -691,9 +693,8 @@ class AccountInternal extends PureComponent<
const allErrors: string[] = [];
for (const transaction of transactions) {
const res: TransactionEntity | null = await send('rules-run', {
transaction,
});
const res: TransactionEntity | null =
await this.props.onRunRules(transaction);
if (res) {
changedTransactions.push(...ungroupTransaction(res));
@@ -1055,10 +1056,9 @@ class AccountInternal extends PureComponent<
});
// run rules on the reconciliation transaction
const runRules = this.props.onRunRules;
const ruledTransactions = await Promise.all(
reconciliationTransactions.map(transaction =>
send('rules-run', { transaction }),
),
reconciliationTransactions.map(transaction => runRules(transaction)),
);
// sync the reconciliation transaction
@@ -2017,9 +2017,13 @@ export function Account() {
const onSyncAndDownload = (id?: AccountEntity['id']) =>
syncAndDownload({ id });
const createPayee = useCreatePayeeMutation();
const { mutateAsync: createPayeeAsync } = useCreatePayeeMutation();
const onCreatePayee = (name: PayeeEntity['name']) =>
createPayee.mutateAsync({ name });
createPayeeAsync({ name });
const { mutateAsync: runRulesAsync } = useRunRulesMutation();
const onRunRules = (transaction: TransactionEntity) =>
runRulesAsync({ transaction });
return (
<SchedulesProvider query={schedulesQuery}>
@@ -2062,6 +2066,7 @@ export function Account() {
onUnlinkAccount={onUnlinkAccount}
onSyncAndDownload={onSyncAndDownload}
onCreatePayee={onCreatePayee}
onRunRules={onRunRules}
/>
</SplitsExpandedProvider>
</SchedulesProvider>

View File

@@ -28,7 +28,7 @@ import {
getFieldError,
getValidOps,
mapField,
unparse,
unparseConditions,
} from 'loot-core/shared/rules';
import { titleFirst } from 'loot-core/shared/util';
import type { IntegerAmount } from 'loot-core/shared/util';
@@ -296,37 +296,39 @@ function ConfigureField<T extends RuleConditionEntity>({
});
}}
>
{type !== 'boolean' && (field !== 'payee' || !isPayeeIdOp(op)) && (
<GenericInput
ref={inputRef}
// @ts-expect-error - fix me
field={field === 'date' || field === 'category' ? subfield : field}
// @ts-expect-error - fix me
type={
type === 'id' &&
(op === 'contains' ||
op === 'matches' ||
op === 'doesNotContain' ||
op === 'hasTags')
? 'string'
: type
}
numberFormatType="currency"
// @ts-expect-error - fix me
value={
formattedValue ?? (op === 'oneOf' || op === 'notOneOf' ? [] : '')
}
// @ts-expect-error - fix me
multi={op === 'oneOf' || op === 'notOneOf'}
op={op}
options={subfieldToOptions(field, subfield)}
style={{ marginTop: 10 }}
// oxlint-disable-next-line typescript/no-explicit-any
onChange={(v: any) => {
dispatch({ type: 'set-value', value: v });
}}
/>
)}
{type &&
type !== 'boolean' &&
(field !== 'payee' || !isPayeeIdOp(op)) && (
<GenericInput
ref={inputRef}
field={
field === 'date' || field === 'category' ? subfield : field
}
type={
type === 'id' &&
(op === 'contains' ||
op === 'matches' ||
op === 'doesNotContain' ||
op === 'hasTags')
? 'string'
: type
}
numberFormatType="currency"
// @ts-expect-error - fix me
value={
formattedValue ??
(op === 'oneOf' || op === 'notOneOf' ? [] : '')
}
multi={op === 'oneOf' || op === 'notOneOf'}
op={op}
options={subfieldToOptions(field, subfield)}
style={{ marginTop: 10 }}
// oxlint-disable-next-line typescript/no-explicit-any
onChange={(v: any) => {
dispatch({ type: 'set-value', value: v });
}}
/>
)}
{field === 'payee' && isPayeeIdOp(op) && (
<PayeeFilter
@@ -424,7 +426,7 @@ export function FilterButton<T extends RuleConditionEntity>({
async function onValidateAndApply(cond: T) {
// @ts-expect-error - fix me
cond = unparse({ ...cond, type: FIELD_TYPES.get(cond.field) });
cond = unparseConditions({ ...cond, type: FIELD_TYPES.get(cond.field) });
if (cond.type === 'date' && cond.options) {
if (cond.options.month) {
@@ -614,7 +616,11 @@ export function FilterEditor<T extends RuleConditionEntity>({
dispatch={dispatch}
onApply={cond => {
// @ts-expect-error - fix me
cond = unparse({ ...cond, type: FIELD_TYPES.get(cond.field) });
cond = unparseConditions({
...cond,
// @ts-expect-error - fix me
type: FIELD_TYPES.get(cond.field),
});
if (cond.type === 'date' && cond.options) {
if (

View File

@@ -17,8 +17,8 @@ import { useNavigate } from '@desktop-client/hooks/useNavigate';
import { useSchedules } from '@desktop-client/hooks/useSchedules';
import { useUndo } from '@desktop-client/hooks/useUndo';
import { pushModal } from '@desktop-client/modals/modalsSlice';
import { addNotification } from '@desktop-client/notifications/notificationsSlice';
import { useDispatch } from '@desktop-client/redux';
import { useDeleteRuleMutation } from '@desktop-client/rules/mutations';
export function MobileRuleEditPage() {
const { t } = useTranslation();
@@ -107,6 +107,8 @@ export function MobileRuleEditPage() {
void navigate(-1);
};
const { mutate: deleteRule } = useDeleteRuleMutation();
const handleDelete = () => {
// Runtime guard to ensure id exists
if (!id || id === 'new') {
@@ -120,23 +122,17 @@ export function MobileRuleEditPage() {
options: {
message: t('Are you sure you want to delete this rule?'),
onConfirm: async () => {
try {
await send('rule-delete', id);
showUndoNotification({
message: t('Rule deleted successfully'),
});
void navigate('/rules');
} catch (error) {
console.error('Failed to delete rule:', error);
dispatch(
addNotification({
notification: {
type: 'error',
message: t('Failed to delete rule. Please try again.'),
},
}),
);
}
deleteRule(
{ id },
{
onSuccess: () => {
showUndoNotification({
message: t('Rule deleted successfully'),
});
void navigate('/rules');
},
},
);
},
},
},

View File

@@ -5,7 +5,7 @@ import { styles } from '@actual-app/components/styles';
import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import { listen, send } from 'loot-core/platform/client/connection';
import { listen } from 'loot-core/platform/client/connection';
import * as undo from 'loot-core/platform/client/undo';
import { getNormalisedString } from 'loot-core/shared/normalisation';
import { q } from 'loot-core/shared/query';
@@ -21,22 +21,24 @@ import { useAccounts } from '@desktop-client/hooks/useAccounts';
import { useCategories } from '@desktop-client/hooks/useCategories';
import { useNavigate } from '@desktop-client/hooks/useNavigate';
import { usePayees } from '@desktop-client/hooks/usePayees';
import { useRules } from '@desktop-client/hooks/useRules';
import { useSchedules } from '@desktop-client/hooks/useSchedules';
import { useUndo } from '@desktop-client/hooks/useUndo';
import { useUrlParam } from '@desktop-client/hooks/useUrlParam';
import { addNotification } from '@desktop-client/notifications/notificationsSlice';
import { useDispatch } from '@desktop-client/redux';
import { useDeleteRuleMutation } from '@desktop-client/rules';
export function MobileRulesPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const dispatch = useDispatch();
const { showUndoNotification } = useUndo();
const [visibleRulesParam] = useUrlParam('visible-rules');
const [allRules, setAllRules] = useState<RuleEntity[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [filter, setFilter] = useState('');
const {
data: allRules = [],
isLoading: isRulesLoading,
refetch: refetchRules,
} = useRules();
const { schedules = [] } = useSchedules({
query: useMemo(() => q('schedules').select('*'), []),
});
@@ -79,28 +81,10 @@ export function MobileRulesPage() {
);
}, [visibleRules, filter, filterData, schedules]);
const loadRules = useCallback(async () => {
try {
setIsLoading(true);
const result = await send('rules-get');
const rules = result || [];
setAllRules(rules);
} catch (error) {
console.error('Failed to load rules:', error);
setAllRules([]);
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
void loadRules();
}, [loadRules]);
// Listen for undo events to refresh rules list
useEffect(() => {
const onUndo = () => {
void loadRules();
void refetchRules();
};
const lastUndoEvent = undo.getUndoState('undoEvent');
@@ -109,7 +93,7 @@ export function MobileRulesPage() {
}
return listen('undo-event', onUndo);
}, [loadRules]);
}, [refetchRules]);
const handleRulePress = useCallback(
(rule: RuleEntity) => {
@@ -125,45 +109,24 @@ export function MobileRulesPage() {
[setFilter],
);
const { mutate: deleteRule } = useDeleteRuleMutation();
const handleRuleDelete = useCallback(
async (rule: RuleEntity) => {
try {
const { someDeletionsFailed } = await send('rule-delete-all', [
rule.id,
]);
if (someDeletionsFailed) {
dispatch(
addNotification({
notification: {
type: 'warning',
message: t(
'This rule could not be deleted because it is linked to a schedule.',
),
},
}),
);
} else {
showUndoNotification({
message: t('Rule deleted successfully'),
});
}
// Refresh the rules list
await loadRules();
} catch (error) {
console.error('Failed to delete rule:', error);
dispatch(
addNotification({
notification: {
type: 'error',
message: t('Failed to delete rule. Please try again.'),
},
}),
);
}
(rule: RuleEntity) => {
deleteRule(
{ id: rule.id },
{
onSuccess: () => {
showUndoNotification({
message: t('Rule deleted successfully'),
});
// Refresh the rules list
void refetchRules();
},
},
);
},
[dispatch, showUndoNotification, t, loadRules],
[deleteRule, showUndoNotification, t, refetchRules],
);
return (
@@ -199,7 +162,7 @@ export function MobileRulesPage() {
</View>
<RulesList
rules={filteredRules}
isLoading={isLoading}
isLoading={isRulesLoading}
onRulePress={handleRulePress}
onRuleDelete={handleRuleDelete}
/>

View File

@@ -12,7 +12,11 @@ import { View } from '@actual-app/components/view';
import { send, sendCatch } from 'loot-core/platform/client/connection';
import * as monthUtils from 'loot-core/shared/months';
import { q } from 'loot-core/shared/query';
import type { RecurConfig, ScheduleEntity } from 'loot-core/types/models';
import type {
RecurConfig,
RuleConditionEntity,
ScheduleEntity,
} from 'loot-core/types/models';
import { MobileBackButton } from '@desktop-client/components/mobile/MobileBackButton';
import { MobilePageHeader, Page } from '@desktop-client/components/Page';

View File

@@ -72,7 +72,6 @@ import {
} from '@desktop-client/components/mobile/MobileForms';
import { getPrettyPayee } from '@desktop-client/components/mobile/utils';
import { MobilePageHeader, Page } from '@desktop-client/components/Page';
import { createSingleTimeScheduleFromTransaction } from '@desktop-client/components/transactions/TransactionList';
import { AmountInput } from '@desktop-client/components/util/AmountInput';
import { useAccounts } from '@desktop-client/hooks/useAccounts';
import { useCategories } from '@desktop-client/hooks/useCategories';
@@ -90,6 +89,10 @@ import { pushModal } from '@desktop-client/modals/modalsSlice';
import { addNotification } from '@desktop-client/notifications/notificationsSlice';
import { aqlQuery } from '@desktop-client/queries/aqlQuery';
import { useDispatch, useSelector } from '@desktop-client/redux';
import {
useCreateSingleTimeScheduleFromTransaction,
useRunRulesMutation,
} from '@desktop-client/rules';
import { setLastTransaction } from '@desktop-client/transactions/transactionsSlice';
function getFieldName(transactionId: TransactionEntity['id'], field: string) {
@@ -671,6 +674,9 @@ const TransactionEditInner = memo<TransactionEditInnerProps>(
[categories, isBudgetTransfer, t],
);
const { mutate: createSingleTimeScheduleFromTransaction } =
useCreateSingleTimeScheduleFromTransaction();
const onSaveInner = useCallback(() => {
const [unserializedTransaction] = unserializedTransactions;
@@ -729,19 +735,24 @@ const TransactionEditInner = memo<TransactionEditInnerProps>(
}
: unserializedTransaction;
await createSingleTimeScheduleFromTransaction(
transactionForSchedule,
);
dispatch(
addNotification({
notification: {
type: 'message',
message: t('Schedule created successfully'),
createSingleTimeScheduleFromTransaction(
{
transaction: transactionForSchedule,
},
{
onSuccess: () => {
dispatch(
addNotification({
notification: {
type: 'message',
message: t('Schedule created successfully'),
},
}),
);
void navigate(-1);
},
}),
},
);
void navigate(-1);
},
onCancel: onConfirmSave,
},
@@ -778,6 +789,7 @@ const TransactionEditInner = memo<TransactionEditInnerProps>(
unserializedTransactions,
upcomingLength,
t,
createSingleTimeScheduleFromTransaction,
]);
const onUpdateInner = useCallback(
@@ -1407,6 +1419,8 @@ function TransactionEditUnconnected({
searchParams,
]);
const { mutateAsync: runRulesAsync } = useRunRulesMutation();
const onUpdate = useCallback(
async (
serializedTransaction: TransactionEntity,
@@ -1422,9 +1436,7 @@ function TransactionEditUnconnected({
// 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 afterRules = await runRulesAsync({ transaction: newTransaction });
const diff = getChangedValues(newTransaction, afterRules);
if (diff) {
@@ -1464,7 +1476,7 @@ function TransactionEditUnconnected({
);
setTransactions(newTransactions);
},
[dateFormat, transactions],
[dateFormat, transactions, runRulesAsync],
);
const onSave = useCallback(

View File

@@ -1,5 +1,4 @@
// @ts-strict-ignore
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
@@ -17,17 +16,16 @@ type ManageRulesModalProps = Extract<
export function ManageRulesModal({ payeeId }: ManageRulesModalProps) {
const { t } = useTranslation();
const [loading, setLoading] = useState(true);
return (
<Modal name="manage-rules" isLoading={loading}>
<Modal name="manage-rules">
{({ state: { close } }) => (
<>
<ModalHeader
title={t('Rules')}
rightContent={<ModalCloseButton onPress={close} />}
/>
<ManageRules isModal payeeId={payeeId} setLoading={setLoading} />
<ManageRules isModal payeeId={payeeId} />
</>
)}
</Modal>

View File

@@ -17,6 +17,7 @@ import { usePayees } from '@desktop-client/hooks/usePayees';
import { replaceModal } from '@desktop-client/modals/modalsSlice';
import type { Modal as ModalType } from '@desktop-client/modals/modalsSlice';
import { useDispatch, useSelector } from '@desktop-client/redux';
import { useAddPayeeRenameRuleMutation } from '@desktop-client/rules';
const highlightStyle = { color: theme.pageTextPositive };
@@ -57,6 +58,9 @@ export function MergeUnusedPayeesModal({
allPayees.filter(p => payeeIds.includes(p.id)),
);
const { mutateAsync: addPayeeRenameRuleAsync } =
useAddPayeeRenameRuleMutation();
const onMerge = useCallback(
async (targetPayee: PayeeEntity) => {
await send('payees-merge', {
@@ -66,7 +70,7 @@ export function MergeUnusedPayeesModal({
let ruleId;
if (shouldCreateRule && !isEditingRule) {
const id = await send('rule-add-payee-rename', {
const id = await addPayeeRenameRuleAsync({
fromNames: payees.map(payee => payee.name),
to: targetPayee.id,
});
@@ -75,7 +79,7 @@ export function MergeUnusedPayeesModal({
return ruleId;
},
[shouldCreateRule, isEditingRule, payees],
[shouldCreateRule, isEditingRule, payees, addPayeeRenameRuleAsync],
);
const onMergeAndCreateRule = useCallback(

View File

@@ -37,8 +37,10 @@ import {
isValidOp,
makeValue,
mapField,
parse,
unparse,
parseActions,
parseConditions,
unparseActions,
unparseConditions,
} from 'loot-core/shared/rules';
import type { ScheduleStatusType } from 'loot-core/shared/schedules';
import type {
@@ -46,6 +48,7 @@ import type {
RuleActionEntity,
RuleEntity,
} from 'loot-core/types/models';
import type { WithOptional } from 'loot-core/types/util';
import { FormulaActionEditor } from './FormulaActionEditor';
@@ -63,9 +66,12 @@ import {
SelectedProvider,
useSelected,
} from '@desktop-client/hooks/useSelected';
import { addNotification } from '@desktop-client/notifications/notificationsSlice';
import { aqlQuery } from '@desktop-client/queries/aqlQuery';
import { useDispatch } from '@desktop-client/redux';
import {
useApplyRuleActionsMutation,
useSaveRuleMutation,
} from '@desktop-client/rules';
import { disableUndo, enableUndo } from '@desktop-client/undo';
function updateValue(array, value, update) {
@@ -958,7 +964,7 @@ function ConditionsList({
}
const getActions = splits => splits.flatMap(s => s.actions);
const getUnparsedActions = splits => getActions(splits).map(unparse);
const getUnparsedActions = splits => getActions(splits).map(unparseActions);
// TODO:
// * Dont touch child transactions?
@@ -996,19 +1002,27 @@ export function RuleEditor({
}: RuleEditorProps) {
const { t } = useTranslation();
const [conditions, setConditions] = useState(
defaultRule.conditions.map(parse).map(c => ({ ...c, inputKey: uuid() })),
defaultRule.conditions
.map(parseConditions)
.map(c => ({ ...c, inputKey: uuid() })),
);
const [actionSplits, setActionSplits] = useState(() => {
const parsedActions = defaultRule.actions.map(parse);
const [actionSplits, setActionSplits] = useState<
Array<{
id: string;
actions: Array<RuleActionEntity & { inputKey: string }>;
}>
>(() => {
const parsedActions = defaultRule.actions.map(parseActions);
return parsedActions.reduce(
(acc, action) => {
const splitIndex = action.options?.splitIndex ?? 0;
const splitIndex =
'options' in action ? (action.options?.splitIndex ?? 0) : 0;
acc[splitIndex] = acc[splitIndex] ?? { id: uuid(), actions: [] };
acc[splitIndex].actions.push({ ...action, inputKey: uuid() });
return acc;
},
// The pre-split group is always there
[{ id: uuid(), actions: [] }],
[{ id: uuid(), actions: [] } as (typeof actionSplits)[0]],
);
});
const [stage, setStage] = useState(defaultRule.stage);
@@ -1039,7 +1053,7 @@ export function RuleEditor({
// Run it here
async function run() {
const { filters } = await send('make-filters-from-conditions', {
conditions: conditions.map(unparse),
conditions: conditions.map(unparseConditions),
});
if (filters.length > 0) {
@@ -1211,74 +1225,67 @@ export function RuleEditor({
});
}
const { mutate: applyRuleActions } = useApplyRuleActionsMutation();
function onApply() {
const selectedTransactions = transactions.filter(({ id }) =>
selectedInst.items.has(id),
);
void send('rule-apply-actions', {
transactions: selectedTransactions,
actions: getUnparsedActions(actionSplits),
}).then(content => {
// This makes it refetch the transactions
content.errors.forEach(error => {
dispatch(
addNotification({
notification: {
type: 'error',
message: error,
},
}),
);
});
setActionSplits([...actionSplits]);
});
applyRuleActions(
{
transactions: selectedTransactions,
ruleActions: getUnparsedActions(actionSplits),
},
{
onSuccess: () => {
setActionSplits([...actionSplits]);
},
},
);
}
const { mutate: saveRule } = useSaveRuleMutation();
async function onSave() {
const rule = {
const rule: WithOptional<RuleEntity, 'id'> = {
...defaultRule,
stage,
conditionsOp,
conditions: conditions.map(unparse),
conditions: conditions.map(unparseConditions),
actions: getUnparsedActions(actionSplits),
};
// @ts-expect-error fix this
const method = rule.id ? 'rule-update' : 'rule-add';
// @ts-expect-error fix this
const { error, id: newId } = await send(method, rule);
saveRule(
{
rule,
},
{
onSuccess: ({ id }) => {
originalOnSave?.({
id,
...rule,
});
},
onError: error => {
if ('conditionErrors' in error && error.conditionErrors) {
setConditions(applyErrors(conditions, error.conditionErrors));
}
if (error) {
// @ts-expect-error fix this
if (error.conditionErrors) {
// @ts-expect-error fix this
setConditions(applyErrors(conditions, error.conditionErrors));
}
// @ts-expect-error fix this
if (error.actionErrors) {
let usedErrorIdx = 0;
setActionSplits(
actionSplits.map(item => ({
...item,
actions: item.actions.map(action => ({
...action,
// @ts-expect-error fix this
error: error.actionErrors[usedErrorIdx++] ?? null,
})),
})),
);
}
} else {
// If adding a rule, we got back an id
if (newId) {
// @ts-expect-error fix this
rule.id = newId;
}
// @ts-expect-error fix this
originalOnSave?.(rule);
}
if ('actionErrors' in error && error.actionErrors) {
let usedErrorIdx = 0;
setActionSplits(
actionSplits.map(item => ({
...item,
actions: item.actions.map(action => ({
...action,
error: error.actionErrors[usedErrorIdx++] ?? null,
})),
})),
);
}
},
},
);
}
// Enable editing existing split rules even if the feature has since been disabled.

View File

@@ -14,7 +14,7 @@ import { usePayeesById } from '@desktop-client/hooks/usePayees';
import { useSchedules } from '@desktop-client/hooks/useSchedules';
type ScheduleValueProps = {
value: ScheduleEntity;
value: ScheduleEntity['id'];
};
export function ScheduleValue({ value }: ScheduleValueProps) {
@@ -35,12 +35,13 @@ export function ScheduleValue({ value }: ScheduleValueProps) {
<Value
value={value}
field="rule"
data={schedules}
// TODO: this manual type coercion does not make much sense -
// should we instead do `schedule._payee.id`?
describe={schedule =>
describeSchedule(schedule, byId[schedule._payee as unknown as string])
}
describe={val => {
const schedule = schedules.find(s => s.id === val);
if (!schedule) {
return t('(deleted)');
}
return describeSchedule(schedule, byId[schedule._payee]);
}}
/>
);
}

View File

@@ -24,7 +24,6 @@ type ValueProps<T> = {
field: unknown;
valueIsRaw?: boolean;
inline?: boolean;
data?: unknown;
describe?: (item: T) => string;
style?: CSSProperties;
};
@@ -34,9 +33,7 @@ export function Value<T>({
field,
valueIsRaw,
inline = false,
data: dataProp,
// @ts-expect-error fix this later
describe = x => x.name,
describe,
style,
}: ValueProps<T>) {
const { t } = useTranslation();
@@ -56,32 +53,6 @@ export function Value<T>({
};
const ValueText = field === 'amount' ? FinancialText : Text;
const locale = useLocale();
function getData() {
if (dataProp) {
return dataProp;
}
switch (field) {
case 'payee':
return payees;
case 'category':
return categories;
case 'category_group':
return categoryGroups;
case 'account':
return accounts;
default:
return [];
}
}
const data = getData();
const [expanded, setExpanded] = useState(false);
function onExpand(e) {
@@ -119,23 +90,39 @@ export function Value<T>({
case 'payee_name':
return value;
case 'payee':
if (valueIsRaw) {
return value;
}
const payee = payees.find(p => p.id === value);
return payee ? (describe?.(value) ?? payee.name) : t('(deleted)');
case 'category':
if (valueIsRaw) {
return value;
}
const category = categories.find(c => c.id === value);
return category
? (describe?.(value) ?? category.name)
: t('(deleted)');
case 'category_group':
if (valueIsRaw) {
return value;
}
const categoryGroup = categoryGroups.find(g => g.id === value);
return categoryGroup
? (describe?.(value) ?? categoryGroup.name)
: t('(deleted)');
case 'account':
if (valueIsRaw) {
return value;
}
const account = accounts.find(a => a.id === value);
return account ? (describe?.(value) ?? account.name) : t('(deleted)');
case 'rule':
if (valueIsRaw) {
return value;
}
if (data && Array.isArray(data)) {
const item = data.find(item => item.id === value);
if (item) {
return describe(item);
} else {
return t('(deleted)');
}
}
return '…';
return describe?.(value) ?? value;
default:
throw new Error(`Unknown field ${field}`);
}

View File

@@ -179,7 +179,6 @@ export function DiscoverSchedules() {
for (const schedule of selected) {
const scheduleId = await send('schedule/create', {
conditions: schedule._conditions,
schedule: {},
});
// Now query for matching transactions and link them automatically

View File

@@ -1,14 +1,18 @@
import { t } from 'i18next';
import { extractScheduleConds } from 'loot-core/shared/schedules';
import type { RuleConditionOp, ScheduleEntity } from 'loot-core/types/models';
import type {
RuleConditionEntity,
RuleConditionOp,
ScheduleEntity,
} from 'loot-core/types/models';
import type { ScheduleFormFields } from './ScheduleEditForm';
export function updateScheduleConditions(
schedule: Partial<ScheduleEntity>,
fields: ScheduleFormFields,
): { error?: string; conditions?: unknown[] } {
): { error?: string; conditions?: RuleConditionEntity[] } {
const conds = extractScheduleConds(schedule._conditions);
const updateCond = (

View File

@@ -8,7 +8,6 @@ import { theme } from '@actual-app/components/theme';
import { send } from 'loot-core/platform/client/connection';
import * as monthUtils from 'loot-core/shared/months';
import { q } from 'loot-core/shared/query';
import { getUpcomingDays } from 'loot-core/shared/schedules';
import {
addSplitTransaction,
@@ -22,7 +21,6 @@ import type {
AccountEntity,
CategoryEntity,
PayeeEntity,
RuleActionEntity,
RuleConditionEntity,
ScheduleEntity,
TransactionEntity,
@@ -38,6 +36,10 @@ import { useSyncedPref } from '@desktop-client/hooks/useSyncedPref';
import { pushModal } from '@desktop-client/modals/modalsSlice';
import { addNotification } from '@desktop-client/notifications/notificationsSlice';
import { useDispatch } from '@desktop-client/redux';
import {
useCreateSingleTimeScheduleFromTransaction,
useRunRulesMutation,
} from '@desktop-client/rules';
// When data changes, there are two ways to update the UI:
//
@@ -82,133 +84,6 @@ async function saveDiffAndApply(diff, changes, onChange, learnCategories) {
);
}
export async function createSingleTimeScheduleFromTransaction(
transaction: TransactionEntity,
): Promise<ScheduleEntity['id']> {
const conditions: RuleConditionEntity[] = [
{ op: 'is', field: 'date', value: transaction.date },
];
const actions: RuleActionEntity[] = [];
const conditionFields = ['amount', 'payee', 'account'];
conditionFields.forEach(field => {
const value = transaction[field];
if (value != null && value !== '') {
conditions.push({
op: 'is',
field,
value,
} as RuleConditionEntity);
}
});
if (transaction.is_parent && transaction.subtransactions) {
if (transaction.notes) {
actions.push({
op: 'set',
field: 'notes',
value: transaction.notes,
options: {
splitIndex: 0,
},
} as RuleActionEntity);
}
transaction.subtransactions.forEach((split, index) => {
const splitIndex = index + 1;
if (split.amount != null) {
actions.push({
op: 'set-split-amount',
value: split.amount,
options: {
splitIndex,
method: 'fixed-amount',
},
} as RuleActionEntity);
}
if (split.category) {
actions.push({
op: 'set',
field: 'category',
value: split.category,
options: {
splitIndex,
},
} as RuleActionEntity);
}
if (split.notes) {
actions.push({
op: 'set',
field: 'notes',
value: split.notes,
options: {
splitIndex,
},
} as RuleActionEntity);
}
});
} else {
if (transaction.category) {
actions.push({
op: 'set',
field: 'category',
value: transaction.category,
} as RuleActionEntity);
}
if (transaction.notes) {
actions.push({
op: 'set',
field: 'notes',
value: transaction.notes,
} as RuleActionEntity);
}
}
const formattedDate = monthUtils.format(transaction.date, 'MMM dd, yyyy');
const timestamp = Date.now();
const scheduleName = `Auto-created future transaction (${formattedDate}) - ${timestamp}`;
const scheduleId = await send('schedule/create', {
conditions,
schedule: {
posts_transaction: true,
name: scheduleName,
},
});
if (actions.length > 0) {
const schedules = await send(
'query',
q('schedules').filter({ id: scheduleId }).select('rule').serialize(),
);
const ruleId = schedules?.data?.[0]?.rule;
if (ruleId) {
const rule = await send('rule-get', { id: ruleId });
if (rule) {
const linkScheduleActions = rule.actions.filter(
a => a.op === 'link-schedule',
);
await send('rule-update', {
...rule,
actions: [...linkScheduleActions, ...actions],
});
}
}
}
return scheduleId;
}
function isFutureTransaction(transaction: TransactionEntity): boolean {
const today = monthUtils.currentDay();
return transaction.date > today;
@@ -375,6 +250,9 @@ export function TransactionList({
[dispatch, onRefetch, upcomingLength, t],
);
const { mutateAsync: createSingleTimeScheduleFromTransactionAsync } =
useCreateSingleTimeScheduleFromTransaction();
const onAdd = useCallback(
async (newTransactions: TransactionEntity[]) => {
newTransactions = realizeTempTransactions(newTransactions);
@@ -397,9 +275,9 @@ export function TransactionList({
promptToConvertToSchedule(
transactionWithSubtransactions,
async () => {
await createSingleTimeScheduleFromTransaction(
transactionWithSubtransactions,
);
await createSingleTimeScheduleFromTransactionAsync({
transaction: transactionWithSubtransactions,
});
},
async () => {
await saveDiff(
@@ -414,7 +292,12 @@ export function TransactionList({
await saveDiff({ added: newTransactions }, isLearnCategoriesEnabled);
onRefetch();
},
[isLearnCategoriesEnabled, onRefetch, promptToConvertToSchedule],
[
isLearnCategoriesEnabled,
onRefetch,
promptToConvertToSchedule,
createSingleTimeScheduleFromTransactionAsync,
],
);
const onSave = useCallback(
@@ -460,7 +343,9 @@ export function TransactionList({
await send('transaction-delete', { id: transaction.id });
}
await createSingleTimeScheduleFromTransaction(transaction);
await createSingleTimeScheduleFromTransactionAsync({
transaction,
});
},
saveTransaction,
);
@@ -470,7 +355,13 @@ export function TransactionList({
await saveTransaction();
},
[isLearnCategoriesEnabled, onChange, onRefetch, promptToConvertToSchedule],
[
isLearnCategoriesEnabled,
onChange,
onRefetch,
promptToConvertToSchedule,
createSingleTimeScheduleFromTransactionAsync,
],
);
const onAddSplit = useCallback(
@@ -503,12 +394,14 @@ export function TransactionList({
[isLearnCategoriesEnabled, onChange],
);
const { mutateAsync: runRulesAsync } = useRunRulesMutation();
const onApplyRules = useCallback(
async (
transaction: TransactionEntity,
updatedFieldName: string | null = null,
) => {
const afterRules = await send('rules-run', { transaction });
const afterRules = await runRulesAsync({ transaction });
// Show formula errors if any
if (afterRules._ruleErrors && afterRules._ruleErrors.length > 0) {
@@ -556,7 +449,7 @@ export function TransactionList({
}
return newTransaction;
},
[dispatch],
[dispatch, runRulesAsync],
);
const onManagePayees = useCallback(

View File

@@ -30,7 +30,7 @@
"colors": ["#141520", "#242733", "#373B4A", "#E8ECF0", "#FFD700", "#8F7A20"]
},
{
"name": "Okabe Ito",
"name": "Color-blind (Dark)",
"repo": "Juulz/okabe-ito",
"colors": ["#222222", "#141520", "#e69f00", "#56b4e9", "#b88115", "#00304d"]
},

View File

@@ -0,0 +1,13 @@
import { useQuery } from '@tanstack/react-query';
import type { PayeeEntity } from 'loot-core/types/models';
import { ruleQueries } from '@desktop-client/rules/queries';
export function usePayeeRules({
payeeId,
}: {
payeeId?: PayeeEntity['id'] | null;
}) {
return useQuery(ruleQueries.listPayee({ payeeId }));
}

View File

@@ -1,6 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { send } from 'loot-core/platform/client/connection';
import { computeSchedulePreviewTransactions } from 'loot-core/shared/schedules';
import { ungroupTransactions } from 'loot-core/shared/transactions';
import type { IntegerAmount } from 'loot-core/shared/util';
@@ -10,6 +9,8 @@ import { useCachedSchedules } from './useCachedSchedules';
import { useSyncedPref } from './useSyncedPref';
import { calculateRunningBalancesBottomUp } from './useTransactions';
import { useRunRulesMutation } from '@desktop-client/rules/mutations';
type UsePreviewTransactionsProps = {
filter?: (schedule: ScheduleEntity) => boolean;
options?: {
@@ -72,6 +73,8 @@ export function usePreviewTransactions({
);
}, [filter, isSchedulesLoading, schedules, statuses, upcomingLength]);
const { mutateAsync: runRulesAsync } = useRunRulesMutation();
useEffect(() => {
let isUnmounted = false;
@@ -88,7 +91,7 @@ export function usePreviewTransactions({
Promise.all(
scheduleTransactions.map(transaction =>
// Kick off an async rules application
send('rules-run', { transaction }),
runRulesAsync({ transaction }),
),
)
.then(newTrans => {
@@ -137,7 +140,13 @@ export function usePreviewTransactions({
return () => {
isUnmounted = true;
};
}, [scheduleTransactions, schedules, statuses, upcomingLength]);
}, [
scheduleTransactions,
schedules,
statuses,
upcomingLength,
runRulesAsync,
]);
const returnError = error || scheduleQueryError;
return {

View File

@@ -0,0 +1,15 @@
import { useQuery } from '@tanstack/react-query';
import type { UseQueryOptions } from '@tanstack/react-query';
import type { RuleEntity } from 'loot-core/types/models';
import { ruleQueries } from '@desktop-client/rules/queries';
type UseRulesOptions = Pick<UseQueryOptions<RuleEntity[]>, 'enabled'>;
export function useRules(options?: UseRulesOptions) {
return useQuery({
...ruleQueries.list(),
...(options ?? {}),
});
}

View File

@@ -0,0 +1,2 @@
export * from './queries';
export * from './mutations';

View File

@@ -0,0 +1,413 @@
import { useTranslation } from 'react-i18next';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import type { QueryClient, QueryKey } from '@tanstack/react-query';
import { v4 as uuidv4 } from 'uuid';
import { send } from 'loot-core/platform/client/connection';
import * as monthUtils from 'loot-core/shared/months';
import { q } from 'loot-core/shared/query';
import type {
NewRuleEntity,
PayeeEntity,
RuleActionEntity,
RuleConditionEntity,
RuleEntity,
ScheduleEntity,
TransactionEntity,
} from 'loot-core/types/models';
import { ruleQueries } from './queries';
import { useRules } from '@desktop-client/hooks/useRules';
import { addNotification } from '@desktop-client/notifications/notificationsSlice';
import { aqlQuery } from '@desktop-client/queries/aqlQuery';
import { useDispatch } from '@desktop-client/redux';
import type { AppDispatch } from '@desktop-client/redux/store';
function invalidateQueries(queryClient: QueryClient, queryKey?: QueryKey) {
void queryClient.invalidateQueries({
queryKey: queryKey ?? ruleQueries.lists(),
});
}
function dispatchErrorNotification(
dispatch: AppDispatch,
message: string,
error?: Error,
) {
dispatch(
addNotification({
notification: {
id: uuidv4(),
type: 'error',
message,
pre: error?.cause ? JSON.stringify(error.cause) : error?.message,
},
}),
);
}
type AddRulePayload = {
rule: Omit<RuleEntity, 'id'>;
};
export function useAddRuleMutation() {
const queryClient = useQueryClient();
const dispatch = useDispatch();
const { t } = useTranslation();
return useMutation({
mutationFn: async ({ rule }: AddRulePayload) => {
return await send('rule-add', rule);
},
onSuccess: () => invalidateQueries(queryClient),
onError: error => {
console.error('Error creating rule:', error);
dispatchErrorNotification(
dispatch,
t('There was an error creating the rule. Please try again.'),
error,
);
},
});
}
type UpdateRulePayload = {
rule: RuleEntity;
};
export function useUpdateRuleMutation() {
const queryClient = useQueryClient();
const dispatch = useDispatch();
const { t } = useTranslation();
return useMutation({
mutationFn: async ({ rule }: UpdateRulePayload) => {
return await send('rule-update', rule);
},
onSuccess: () => invalidateQueries(queryClient),
onError: error => {
console.error('Error updating rule:', error);
dispatchErrorNotification(
dispatch,
t('There was an error updating the rule. Please try again.'),
error,
);
},
});
}
type SaveRulePayload = {
rule: RuleEntity | NewRuleEntity;
};
export function useSaveRuleMutation() {
const { mutateAsync: updateRuleAsync } = useUpdateRuleMutation();
const { mutateAsync: addRuleAsync } = useAddRuleMutation();
return useMutation({
mutationFn: async ({ rule }: SaveRulePayload) => {
if ('id' in rule && rule.id) {
return await updateRuleAsync({ rule });
} else {
return await addRuleAsync({ rule });
}
},
});
}
type DeleteRulePayload = {
id: RuleEntity['id'];
};
export function useDeleteRuleMutation() {
const queryClient = useQueryClient();
const dispatch = useDispatch();
const { t } = useTranslation();
return useMutation({
mutationFn: async ({ id }: DeleteRulePayload) => {
return await send('rule-delete', id);
},
onSuccess: () => invalidateQueries(queryClient),
onError: error => {
console.error('Error deleting rule:', error);
dispatchErrorNotification(
dispatch,
t('There was an error deleting the rule. Please try again.'),
error,
);
},
});
}
type DeleteAllRulesPayload = {
ids: Array<RuleEntity['id']>;
};
export function useBatchDeleteRulesMutation() {
const queryClient = useQueryClient();
const dispatch = useDispatch();
const { t } = useTranslation();
return useMutation({
mutationFn: async ({ ids }: DeleteAllRulesPayload) => {
return await send('rule-delete-all', ids);
},
onSuccess: () => invalidateQueries(queryClient),
onError: error => {
console.error('Error deleting rules:', error);
dispatchErrorNotification(
dispatch,
t('There was an error deleting rules. Please try again.'),
error,
);
},
});
}
type ApplyRuleActionsPayload = {
transactions: TransactionEntity[];
ruleActions: RuleActionEntity[];
};
export function useApplyRuleActionsMutation() {
const queryClient = useQueryClient();
const dispatch = useDispatch();
const { t } = useTranslation();
return useMutation({
mutationFn: async ({
transactions,
ruleActions,
}: ApplyRuleActionsPayload) => {
const result = await send('rule-apply-actions', {
transactions,
actions: ruleActions,
});
if (result && result.errors && result.errors.length > 0) {
throw new Error('Error applying rule actions.', {
cause: result.errors,
});
}
},
onSuccess: () => invalidateQueries(queryClient),
onError: error => {
console.error('Error applying rule actions:', error);
dispatchErrorNotification(
dispatch,
t('There was an error applying the rule actions. Please try again.'),
error,
);
},
});
}
type AddPayeeRenameRulePayload = {
fromNames: Array<PayeeEntity['name']>;
to: PayeeEntity['id'];
};
export function useAddPayeeRenameRuleMutation() {
const queryClient = useQueryClient();
const dispatch = useDispatch();
const { t } = useTranslation();
return useMutation({
mutationFn: async ({ fromNames, to }: AddPayeeRenameRulePayload) => {
return await send('rule-add-payee-rename', {
fromNames,
to,
});
},
onSuccess: () => invalidateQueries(queryClient),
onError: error => {
console.error('Error adding payee rename rule:', error);
dispatchErrorNotification(
dispatch,
t('There was an error adding the payee rename rule. Please try again.'),
error,
);
},
});
}
type RunRulesPayload = {
transaction: TransactionEntity;
};
export function useRunRulesMutation() {
const queryClient = useQueryClient();
const dispatch = useDispatch();
const { t } = useTranslation();
return useMutation({
mutationFn: async ({ transaction }: RunRulesPayload) => {
return await send('rules-run', { transaction });
},
onSuccess: () => invalidateQueries(queryClient),
onError: error => {
console.error('Error running rules for transaction:', error);
dispatchErrorNotification(
dispatch,
t(
'There was an error running the rules for transaction. Please try again.',
),
error,
);
},
});
}
// TODO: Move to schedules mutations file once we have schedule-related mutations
export function useCreateSingleTimeScheduleFromTransaction() {
const queryClient = useQueryClient();
const dispatch = useDispatch();
const { t } = useTranslation();
const { data: allRules = [] } = useRules();
const { mutateAsync: updateRuleAsync } = useUpdateRuleMutation();
return useMutation({
mutationFn: async ({
transaction,
}: {
transaction: TransactionEntity;
}): Promise<ScheduleEntity['id']> => {
const conditions: RuleConditionEntity[] = [
{ op: 'is', field: 'date', value: transaction.date },
];
const actions: RuleActionEntity[] = [];
const conditionFields = ['amount', 'payee', 'account'] as const;
conditionFields.forEach(field => {
const value = transaction[field];
if (value != null && value !== '') {
conditions.push({
op: 'is',
field,
value,
} as RuleConditionEntity);
}
});
if (transaction.is_parent && transaction.subtransactions) {
if (transaction.notes) {
actions.push({
op: 'set',
field: 'notes',
value: transaction.notes,
options: {
splitIndex: 0,
},
} as RuleActionEntity);
}
transaction.subtransactions.forEach((split, index) => {
const splitIndex = index + 1;
if (split.amount != null) {
actions.push({
op: 'set-split-amount',
value: split.amount,
options: {
splitIndex,
method: 'fixed-amount',
},
} as RuleActionEntity);
}
if (split.category) {
actions.push({
op: 'set',
field: 'category',
value: split.category,
options: {
splitIndex,
},
} as RuleActionEntity);
}
if (split.notes) {
actions.push({
op: 'set',
field: 'notes',
value: split.notes,
options: {
splitIndex,
},
} as RuleActionEntity);
}
});
} else {
if (transaction.category) {
actions.push({
op: 'set',
field: 'category',
value: transaction.category,
} as RuleActionEntity);
}
if (transaction.notes) {
actions.push({
op: 'set',
field: 'notes',
value: transaction.notes,
} as RuleActionEntity);
}
}
const formattedDate = monthUtils.format(transaction.date, 'MMM dd, yyyy');
const timestamp = Date.now();
const scheduleName = `Auto-created future transaction (${formattedDate}) - ${timestamp}`;
const scheduleId = await send('schedule/create', {
conditions,
schedule: {
posts_transaction: true,
name: scheduleName,
},
});
if (actions.length > 0) {
const schedules = await aqlQuery(
q('schedules').filter({ id: scheduleId }).select('rule'),
);
const ruleId = schedules?.data?.[0]?.rule;
if (ruleId) {
const rule = allRules.find(r => r.id === ruleId);
if (rule) {
const linkScheduleActions = rule.actions.filter(
a => a.op === 'link-schedule',
);
await updateRuleAsync({
rule: {
...rule,
actions: [...linkScheduleActions, ...actions],
},
});
}
}
}
return scheduleId;
},
onSuccess: () => invalidateQueries(queryClient),
onError: error => {
console.error('Error creating schedule from transaction:', error);
dispatchErrorNotification(
dispatch,
t(
'There was an error creating the schedule from the transaction. Please try again.',
),
error,
);
},
});
}

View File

@@ -0,0 +1,31 @@
import { queryOptions } from '@tanstack/react-query';
import { send } from 'loot-core/platform/client/connection';
import type { PayeeEntity, RuleEntity } from 'loot-core/types/models';
export const ruleQueries = {
all: () => ['rules'] as const,
lists: () => [...ruleQueries.all(), 'lists'] as const,
list: () =>
queryOptions<RuleEntity[]>({
queryKey: [...ruleQueries.lists()],
queryFn: async () => {
return await send('rules-get');
},
staleTime: Infinity,
}),
listPayee: ({ payeeId }: { payeeId?: PayeeEntity['id'] | null }) =>
queryOptions<RuleEntity[]>({
queryKey: [...ruleQueries.lists(), { payeeId }] as const,
queryFn: async () => {
if (!payeeId) {
// Should never happen since the query is disabled when payeeId is not provided,
// but is needed to satisfy TypeScript.
throw new Error('payeeId is required.');
}
return await send('payees-get-rules', { id: payeeId });
},
staleTime: Infinity,
enabled: !!payeeId,
}),
};

View File

@@ -778,24 +778,20 @@ handlers['api/payee-rules-get'] = async function ({ id }) {
handlers['api/rule-create'] = withMutation(async function ({ rule }) {
checkFileOpen();
const addedRule = await handlers['rule-add'](rule);
if ('error' in addedRule) {
throw APIError('Failed creating a new rule', addedRule.error);
try {
return await handlers['rule-add'](rule);
} catch (error) {
throw APIError('Failed creating a new rule', error);
}
return addedRule;
});
handlers['api/rule-update'] = withMutation(async function ({ rule }) {
checkFileOpen();
const updatedRule = await handlers['rule-update'](rule);
if ('error' in updatedRule) {
throw APIError('Failed updating the rule', updatedRule.error);
try {
return await handlers['rule-update'](rule);
} catch (error) {
throw APIError('Failed updating the rule', error);
}
return updatedRule;
});
handlers['api/rule-delete'] = withMutation(async function (id) {

View File

@@ -1,6 +1,7 @@
// @ts-strict-ignore
import { logger } from '../../platform/server/log';
import type {
PayeeEntity,
RuleActionEntity,
RuleEntity,
TransactionEntity,
@@ -76,9 +77,9 @@ export type RulesHandlers = {
'rule-delete-all': typeof deleteAllRules;
'rule-apply-actions': typeof applyRuleActions;
'rule-add-payee-rename': typeof addRulePayeeRename;
'rules-run': typeof runRules;
'rules-get': typeof getRules;
'rule-get': typeof getRule;
'rules-run': typeof runRules;
};
// Expose functions to the client
@@ -91,9 +92,9 @@ app.method('rule-delete', mutator(undoable(deleteRule)));
app.method('rule-delete-all', mutator(undoable(deleteAllRules)));
app.method('rule-apply-actions', mutator(undoable(applyRuleActions)));
app.method('rule-add-payee-rename', mutator(addRulePayeeRename));
app.method('rules-run', mutator(runRules));
app.method('rules-get', getRules);
app.method('rule-get', getRule);
app.method('rules-run', runRules);
async function ruleValidate(
rule: Partial<RuleEntity>,
@@ -102,24 +103,20 @@ async function ruleValidate(
return { error };
}
async function addRule(
rule: Omit<RuleEntity, 'id'>,
): Promise<{ error: ValidationError } | RuleEntity> {
async function addRule(rule: Omit<RuleEntity, 'id'>): Promise<RuleEntity> {
const error = validateRule(rule);
if (error) {
return { error };
throw error;
}
const id = await rules.insertRule(rule);
return { id, ...rule };
}
async function updateRule(
rule: RuleEntity,
): Promise<{ error: ValidationError } | RuleEntity> {
async function updateRule(rule: RuleEntity): Promise<RuleEntity> {
const error = validateRule(rule);
if (error) {
return { error };
throw error;
}
await rules.updateRule(rule);
@@ -127,24 +124,32 @@ async function updateRule(
}
async function deleteRule(id: RuleEntity['id']) {
return rules.deleteRule(id);
const isSuccess = await rules.deleteRule(id);
if (!isSuccess) {
throw new Error(
'Error deleting rule. The rule may be linked to a schedule which prevents it from being deleted.',
);
}
return isSuccess;
}
async function deleteAllRules(
ids: Array<RuleEntity['id']>,
): Promise<{ someDeletionsFailed: boolean }> {
let someDeletionsFailed = false;
async function deleteAllRules(ids: Array<RuleEntity['id']>): Promise<void> {
const failedIds: Array<RuleEntity['id']> = [];
await batchMessages(async () => {
for (const id of ids) {
const res = await rules.deleteRule(id);
if (res === false) {
someDeletionsFailed = true;
const isSuccess = await rules.deleteRule(id);
if (!isSuccess) {
failedIds.push(id);
}
}
});
return { someDeletionsFailed };
if (failedIds.length > 0) {
throw new Error(
`Error deleting ${failedIds.length} rules. These rules may be linked to schedules which prevents them from being deleted.`,
);
}
}
async function applyRuleActions({
@@ -165,8 +170,8 @@ async function addRulePayeeRename({
fromNames,
to,
}: {
fromNames: string[];
to: string;
fromNames: Array<PayeeEntity['name']>;
to: PayeeEntity['id'];
}): Promise<string> {
return rules.updatePayeeRenameRule(fromNames, to);
}

View File

@@ -3,6 +3,8 @@ import * as d from 'date-fns';
import deepEqual from 'deep-equal';
import { v4 as uuidv4 } from 'uuid';
import type { WithRequired } from 'loot-core/types/util';
import { captureBreadcrumb } from '../../platform/exceptions';
import * as connection from '../../platform/server/connection';
import { logger } from '../../platform/server/log';
@@ -17,7 +19,7 @@ import {
getStatus,
recurConfigToRSchedule,
} from '../../shared/schedules';
import type { ScheduleEntity } from '../../types/models';
import type { RuleConditionEntity, ScheduleEntity } from '../../types/models';
import { addTransactions } from '../accounts/sync';
import { createApp } from '../app';
import { aqlQuery } from '../aql';
@@ -184,10 +186,13 @@ async function checkIfScheduleExists(name, scheduleId) {
}
export async function createSchedule({
schedule = null,
schedule = {},
conditions = [],
} = {}): Promise<ScheduleEntity['id']> {
const scheduleId = schedule?.id || uuidv4();
}: {
schedule?: Partial<Omit<ScheduleEntity, 'id'>>;
conditions?: RuleConditionEntity[];
}): Promise<ScheduleEntity['id']> {
const scheduleId = uuidv4();
const { date: dateCond } = extractScheduleConds(conditions);
if (dateCond == null) {
@@ -199,14 +204,12 @@ export async function createSchedule({
const nextDate = getNextDate(dateCond);
const nextDateRepr = nextDate ? toDateRepr(nextDate) : null;
if (schedule) {
if (schedule.name) {
if (await checkIfScheduleExists(schedule.name, scheduleId)) {
throw new Error('Cannot create schedules with the same name');
}
} else {
schedule.name = null;
if (schedule.name) {
if (await checkIfScheduleExists(schedule.name, scheduleId)) {
throw new Error('Cannot create schedules with the same name');
}
} else {
schedule.name = null;
}
// Create the rule here based on the info
@@ -242,8 +245,8 @@ export async function updateSchedule({
conditions,
resetNextDate,
}: {
schedule;
conditions?;
schedule: WithRequired<Partial<ScheduleEntity>, 'id'>;
conditions?: RuleConditionEntity[];
resetNextDate?: boolean;
}) {
if (schedule.rule) {

View File

@@ -13,6 +13,7 @@ import { getApproxNumberThreshold, sortNumbers } from '../../shared/rules';
import { ungroupTransaction } from '../../shared/transactions';
import { fastSetMerge, partitionByField } from '../../shared/util';
import type {
PayeeEntity,
RuleActionEntity,
RuleEntity,
TransactionEntity,
@@ -784,7 +785,10 @@ function* getOneOfSetterRules(
return null;
}
export async function updatePayeeRenameRule(fromNames: string[], to: string) {
export async function updatePayeeRenameRule(
fromNames: Array<PayeeEntity['name']>,
to: PayeeEntity['id'],
) {
const renameRule = getOneOfSetterRules('pre', 'imported_payee', 'payee', {
actionValue: to,
}).next().value;

View File

@@ -1,7 +1,12 @@
// @ts-strict-ignore
import { t } from 'i18next';
import type { FieldValueTypes, RuleConditionOp } from '../types/models';
import type {
FieldValueTypes,
RuleActionEntity,
RuleConditionEntity,
RuleConditionOp,
} from '../types/models';
// For now, this info is duplicated from the backend. Figure out how
// to share it later.
@@ -90,11 +95,11 @@ const FIELD_INFO = {
const fieldInfo: FieldInfoConstraint = FIELD_INFO;
export const FIELD_TYPES = new Map<keyof FieldValueTypes, string>(
Object.entries(FIELD_INFO).map(([field, info]) => [
field as unknown as keyof FieldValueTypes,
info.type,
]),
export const FIELD_TYPES = new Map(
Object.entries(FIELD_INFO).map(
([field, info]) =>
[field as unknown as keyof FieldValueTypes, info.type] as const,
),
);
export function isValidOp(field: keyof FieldValueTypes, op: RuleConditionOp) {
@@ -104,6 +109,7 @@ export function isValidOp(field: keyof FieldValueTypes, op: RuleConditionOp) {
if (fieldInfo[field].disallowedOps?.has(op)) return false;
return (
// @ts-expect-error Fix op type. RuleConditionEntity is really tricky to work with...
TYPE_INFO[type].ops.includes(op) || fieldInfo[field].internalOps?.has(op)
);
}
@@ -292,24 +298,21 @@ export function sortNumbers(num1, num2) {
return [num2, num1];
}
export function parse(item) {
if (item.op === 'set-split-amount') {
if (item.options.method === 'fixed-amount') {
return { ...item };
}
return item;
}
export function parseConditions(
item: RuleConditionEntity,
): RuleConditionEntity & { error?: string | null } {
switch (item.type) {
case 'number': {
return { ...item };
}
case 'string': {
const parsed = item.value == null ? '' : item.value;
// @ts-expect-error Fix me
return { ...item, value: parsed };
}
case 'boolean': {
const parsed = item.value;
// @ts-expect-error Fix me
return { ...item, value: parsed };
}
default:
@@ -318,7 +321,74 @@ export function parse(item) {
return { ...item, error: null };
}
export function unparse({ error: _error, inputKey: _inputKey, ...item }) {
export function unparseConditions({
error: _error,
inputKey: _inputKey,
...item
}: RuleConditionEntity & {
inputKey?: string;
error?: string | null;
}): RuleConditionEntity {
if ('type' in item && item.type) {
switch (item.type) {
case 'number': {
return { ...item };
}
case 'string': {
const unparsed = item.value == null ? '' : item.value;
// @ts-expect-error Fix me
return { ...item, value: unparsed };
}
case 'boolean': {
const unparsed = item.value == null ? false : item.value;
// @ts-expect-error Fix me
return { ...item, value: unparsed };
}
default:
}
}
return item;
}
export function parseActions(
item: RuleActionEntity,
): RuleActionEntity & { error?: string | null } {
if (item.op === 'set-split-amount') {
if (item.options.method === 'fixed-amount') {
return { ...item };
}
return item;
}
if ('type' in item && item.type) {
switch (item.type) {
case 'number': {
return { ...item };
}
case 'string': {
const parsed = item.value == null ? '' : item.value;
return { ...item, value: parsed };
}
case 'boolean': {
const parsed = item.value;
return { ...item, value: parsed };
}
default:
}
}
return { ...item, error: null };
}
export function unparseActions({
error: _error,
inputKey: _inputKey,
...item
}: RuleActionEntity & {
inputKey?: string;
error?: string | null;
}): RuleActionEntity {
if (item.op === 'set-split-amount') {
if (item.options.method === 'fixed-amount') {
return {
@@ -328,25 +398,27 @@ export function unparse({ error: _error, inputKey: _inputKey, ...item }) {
if (item.options.method === 'fixed-percent') {
return {
...item,
value: item.value && parseFloat(item.value),
value: item.value && parseFloat(`${item.value}`),
};
}
return item;
}
switch (item.type) {
case 'number': {
return { ...item };
if ('type' in item && item.type) {
switch ('type' in item && item.type) {
case 'number': {
return { ...item };
}
case 'string': {
const unparsed = item.value == null ? '' : item.value;
return { ...item, value: unparsed };
}
case 'boolean': {
const unparsed = item.value == null ? false : item.value;
return { ...item, value: unparsed };
}
default:
}
case 'string': {
const unparsed = item.value == null ? '' : item.value;
return { ...item, value: unparsed };
}
case 'boolean': {
const unparsed = item.value == null ? false : item.value;
return { ...item, value: unparsed };
}
default:
}
return item;

View File

@@ -155,7 +155,7 @@ export type SetSplitAmountRuleActionEntity = {
export type LinkScheduleRuleActionEntity = {
op: 'link-schedule';
value: ScheduleEntity;
value: ScheduleEntity['id'];
};
export type PrependNoteRuleActionEntity = {

View File

@@ -0,0 +1,6 @@
---
category: Maintenance
authors: [Juulz]
---
Rename 'Okabe Ito' theme to 'Color-blind (dark)'.

View File

@@ -0,0 +1,6 @@
---
category: Maintenance
authors: [joel-jeremy]
---
Introduce React Query hooks for rules management, enhancing data-fetching and mutation capabilities.