mirror of
https://github.com/actualbudget/actual.git
synced 2026-03-09 03:32:54 -05:00
Compare commits
6 Commits
react-quer
...
react-quer
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c190dc480 | ||
|
|
b288ce5708 | ||
|
|
8630a4fda6 | ||
|
|
2cc9daf50a | ||
|
|
fbc1025c2b | ||
|
|
a1e0b3f45d |
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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');
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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]);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"]
|
||||
},
|
||||
|
||||
13
packages/desktop-client/src/hooks/usePayeeRules.ts
Normal file
13
packages/desktop-client/src/hooks/usePayeeRules.ts
Normal 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 }));
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
15
packages/desktop-client/src/hooks/useRules.ts
Normal file
15
packages/desktop-client/src/hooks/useRules.ts
Normal 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 ?? {}),
|
||||
});
|
||||
}
|
||||
2
packages/desktop-client/src/rules/index.ts
Normal file
2
packages/desktop-client/src/rules/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './queries';
|
||||
export * from './mutations';
|
||||
413
packages/desktop-client/src/rules/mutations.ts
Normal file
413
packages/desktop-client/src/rules/mutations.ts
Normal 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,
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
31
packages/desktop-client/src/rules/queries.ts
Normal file
31
packages/desktop-client/src/rules/queries.ts
Normal 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,
|
||||
}),
|
||||
};
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -155,7 +155,7 @@ export type SetSplitAmountRuleActionEntity = {
|
||||
|
||||
export type LinkScheduleRuleActionEntity = {
|
||||
op: 'link-schedule';
|
||||
value: ScheduleEntity;
|
||||
value: ScheduleEntity['id'];
|
||||
};
|
||||
|
||||
export type PrependNoteRuleActionEntity = {
|
||||
|
||||
6
upcoming-release-notes/7058.md
Normal file
6
upcoming-release-notes/7058.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Maintenance
|
||||
authors: [Juulz]
|
||||
---
|
||||
|
||||
Rename 'Okabe Ito' theme to 'Color-blind (dark)'.
|
||||
6
upcoming-release-notes/7070.md
Normal file
6
upcoming-release-notes/7070.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Maintenance
|
||||
authors: [joel-jeremy]
|
||||
---
|
||||
|
||||
Introduce React Query hooks for rules management, enhancing data-fetching and mutation capabilities.
|
||||
Reference in New Issue
Block a user