Compare commits

...

11 Commits

Author SHA1 Message Date
Joel Jeremy Marquez
4fae08e07b Fix lint error 2026-03-18 09:36:42 -07:00
Joel Jeremy Marquez
72be07e29b Feedback 2026-03-18 09:33:31 -07:00
Joel Jeremy Marquez
1af1591da3 Fix typecheck error 2026-03-16 17:24:16 +00:00
Joel Jeremy Marquez
51b75df429 Merge remote-tracking branch 'origin/master' into react-query-rules 2026-03-16 15:56:48 +00:00
Joel Jeremy Marquez
c21f85a399 Merge remote-tracking branch 'origin/master' into react-query-rules 2026-03-13 17:00:50 +00:00
Joel Jeremy Marquez
047fa3c6c5 Merge remote-tracking branch 'origin/master' into react-query-rules 2026-03-13 09:41:50 -07:00
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
28 changed files with 1016 additions and 537 deletions

View File

@@ -1,16 +1,16 @@
// @ts-strict-ignore // @ts-strict-ignore
import React, { useEffect, useEffectEvent, useMemo, useState } from 'react'; import React, { useEffect, useEffectEvent, useMemo, useState } from 'react';
import type { Dispatch, SetStateAction } from 'react';
import { Trans, useTranslation } from 'react-i18next'; import { Trans, useTranslation } from 'react-i18next';
import { Button } from '@actual-app/components/button'; import { Button } from '@actual-app/components/button';
import { AnimatedLoading } from '@actual-app/components/icons/AnimatedLoading';
import { SpaceBetween } from '@actual-app/components/space-between'; import { SpaceBetween } from '@actual-app/components/space-between';
import { styles } from '@actual-app/components/styles'; import { styles } from '@actual-app/components/styles';
import { Text } from '@actual-app/components/text'; import { Text } from '@actual-app/components/text';
import { theme } from '@actual-app/components/theme'; import { theme } from '@actual-app/components/theme';
import { Tooltip } from '@actual-app/components/tooltip';
import { View } from '@actual-app/components/view'; 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 * as undo from 'loot-core/platform/client/undo';
import { getNormalisedString } from 'loot-core/shared/normalisation'; import { getNormalisedString } from 'loot-core/shared/normalisation';
import { q } from 'loot-core/shared/query'; import { q } from 'loot-core/shared/query';
@@ -30,7 +30,9 @@ import { RulesList } from './rules/RulesList';
import { useAccounts } from '@desktop-client/hooks/useAccounts'; import { useAccounts } from '@desktop-client/hooks/useAccounts';
import { useCategories } from '@desktop-client/hooks/useCategories'; import { useCategories } from '@desktop-client/hooks/useCategories';
import { usePayeeRules } from '@desktop-client/hooks/usePayeeRules';
import { usePayees } from '@desktop-client/hooks/usePayees'; import { usePayees } from '@desktop-client/hooks/usePayees';
import { useRules } from '@desktop-client/hooks/useRules';
import { useSchedules } from '@desktop-client/hooks/useSchedules'; import { useSchedules } from '@desktop-client/hooks/useSchedules';
import { import {
SelectedProvider, SelectedProvider,
@@ -38,6 +40,10 @@ import {
} from '@desktop-client/hooks/useSelected'; } from '@desktop-client/hooks/useSelected';
import { pushModal } from '@desktop-client/modals/modalsSlice'; import { pushModal } from '@desktop-client/modals/modalsSlice';
import { useDispatch } from '@desktop-client/redux'; import { useDispatch } from '@desktop-client/redux';
import {
useBatchDeleteRulesMutation,
useDeleteRuleMutation,
} from '@desktop-client/rules';
export type FilterData = { export type FilterData = {
payees?: Array<{ id: string; name: string }>; payees?: Array<{ id: string; name: string }>;
@@ -115,17 +121,36 @@ export function ruleToString(rule: RuleEntity, data: FilterData) {
type ManageRulesProps = { type ManageRulesProps = {
isModal: boolean; isModal: boolean;
payeeId: string | null; payeeId: string | null;
setLoading?: Dispatch<SetStateAction<boolean>>;
}; };
export function ManageRules({ export function ManageRules({ isModal, payeeId }: ManageRulesProps) {
isModal,
payeeId,
setLoading = () => {},
}: ManageRulesProps) {
const { t } = useTranslation(); 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 [page, setPage] = useState(0);
const [filter, setFilter] = useState(''); const [filter, setFilter] = useState('');
const dispatch = useDispatch(); const dispatch = useDispatch();
@@ -147,7 +172,7 @@ export function ManageRules({
); );
const filteredRules = useMemo(() => { const filteredRules = useMemo(() => {
const rules = allRules.filter(rule => { const rules = rulesToUse.filter(rule => {
const schedule = schedules.find(schedule => schedule.rule === rule.id); const schedule = schedules.find(schedule => schedule.rule === rule.id);
return schedule ? schedule.completed === false : true; return schedule ? schedule.completed === false : true;
}); });
@@ -161,7 +186,7 @@ export function ManageRules({
), ),
) )
).slice(0, 100 + page * 50); ).slice(0, 100 + page * 50);
}, [allRules, filter, filterData, page, schedules]); }, [rulesToUse, filter, filterData, page, schedules]);
const selectedInst = useSelected('manage-rules', filteredRules, []); const selectedInst = useSelected('manage-rules', filteredRules, []);
const [hoveredRule, setHoveredRule] = useState(null); const [hoveredRule, setHoveredRule] = useState(null);
@@ -171,38 +196,16 @@ export function ManageRules({
setPage(0); 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(() => { const init = useEffectEvent(() => {
async function loadData() {
await loadRules();
setLoading(false);
}
if (payeeId) { if (payeeId) {
undo.setUndoState('openModal', { name: 'manage-rules', options: {} }); undo.setUndoState('openModal', { name: 'manage-rules', options: {} });
} }
void loadData();
return () => { return () => {
undo.setUndoState('openModal', null); undo.setUndoState('openModal', null);
}; };
}); });
useEffect(() => { useEffect(() => {
return init(); return init();
}, []); }, []);
@@ -211,29 +214,33 @@ export function ManageRules({
setPage(page => page + 1); setPage(page => page + 1);
} }
const { mutate: batchDeleteRules } = useBatchDeleteRulesMutation();
const onDeleteSelected = async () => { const onDeleteSelected = async () => {
setLoading(true); batchDeleteRules(
{
const { someDeletionsFailed } = await send('rule-delete-all', [ ids: [...selectedInst.items],
...selectedInst.items, },
]); {
onSuccess: () => {
if (someDeletionsFailed) { void refetchRules();
alert( selectedInst.dispatch({ type: 'select-none' });
t('Some rules were not deleted because they are linked to schedules.'), },
); },
} );
await loadRules();
selectedInst.dispatch({ type: 'select-none' });
setLoading(false);
}; };
async function onDeleteRule(id: string) { const { mutate: deleteRule } = useDeleteRuleMutation();
setLoading(true);
await send('rule-delete', id); function onDeleteRule(id: string) {
await loadRules(); deleteRule(
setLoading(false); { id },
{
onSuccess: () => {
void refetchRules();
},
},
);
} }
const onEditRule = rule => { const onEditRule = rule => {
@@ -244,8 +251,7 @@ export function ManageRules({
options: { options: {
rule, rule,
onSave: async () => { onSave: async () => {
await loadRules(); void refetchRules();
setLoading(false);
}, },
}, },
}, },
@@ -282,8 +288,7 @@ export function ManageRules({
options: { options: {
rule, rule,
onSave: async () => { onSave: async () => {
await loadRules(); void refetchRules();
setLoading(false);
}, },
}, },
}, },
@@ -295,6 +300,24 @@ export function ManageRules({
setHoveredRule(id); 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 ( return (
<SelectedProvider instance={selectedInst}> <SelectedProvider instance={selectedInst}>
<View> <View>
@@ -361,11 +384,24 @@ export function ManageRules({
> >
<SpaceBetween gap={10} style={{ justifyContent: 'flex-end' }}> <SpaceBetween gap={10} style={{ justifyContent: 'flex-end' }}>
{selectedInst.items.size > 0 && ( {selectedInst.items.size > 0 && (
<Button onPress={onDeleteSelected}> <Tooltip
<Trans count={selectedInst.items.size}> isOpen={isNonDeletableRuleSelected}
Delete {{ count: selectedInst.items.size }} rules content={
</Trans> <Trans>
</Button> 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}> <Button variant="primary" onPress={onCreateRule}>
<Trans>Create new rule</Trans> <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 type { PagedQuery } from '@desktop-client/queries/pagedQuery';
import { useDispatch, useSelector } from '@desktop-client/redux'; import { useDispatch, useSelector } from '@desktop-client/redux';
import type { AppDispatch } from '@desktop-client/redux/store'; import type { AppDispatch } from '@desktop-client/redux/store';
import { useRunRulesMutation } from '@desktop-client/rules/mutations';
import { updateNewTransactions } from '@desktop-client/transactions/transactionsSlice'; import { updateNewTransactions } from '@desktop-client/transactions/transactionsSlice';
type ConditionEntity = Partial<RuleConditionEntity> | TransactionFilterEntity; type ConditionEntity = Partial<RuleConditionEntity> | TransactionFilterEntity;
@@ -251,6 +252,7 @@ type AccountInternalProps = {
onUnlinkAccount: (id: AccountEntity['id']) => void; onUnlinkAccount: (id: AccountEntity['id']) => void;
onSyncAndDownload: (accountId?: AccountEntity['id']) => void; onSyncAndDownload: (accountId?: AccountEntity['id']) => void;
onCreatePayee: (name: PayeeEntity['name']) => Promise<PayeeEntity['id']>; onCreatePayee: (name: PayeeEntity['name']) => Promise<PayeeEntity['id']>;
onRunRules: (transaction: TransactionEntity) => Promise<TransactionEntity>;
}; };
type AccountInternalState = { type AccountInternalState = {
@@ -691,9 +693,8 @@ class AccountInternal extends PureComponent<
const allErrors: string[] = []; const allErrors: string[] = [];
for (const transaction of transactions) { for (const transaction of transactions) {
const res: TransactionEntity | null = await send('rules-run', { const res: TransactionEntity | null =
transaction, await this.props.onRunRules(transaction);
});
if (res) { if (res) {
changedTransactions.push(...ungroupTransaction(res)); changedTransactions.push(...ungroupTransaction(res));
@@ -1055,10 +1056,9 @@ class AccountInternal extends PureComponent<
}); });
// run rules on the reconciliation transaction // run rules on the reconciliation transaction
const runRules = this.props.onRunRules;
const ruledTransactions = await Promise.all( const ruledTransactions = await Promise.all(
reconciliationTransactions.map(transaction => reconciliationTransactions.map(transaction => runRules(transaction)),
send('rules-run', { transaction }),
),
); );
// sync the reconciliation transaction // sync the reconciliation transaction
@@ -2028,9 +2028,13 @@ export function Account() {
const onSyncAndDownload = (id?: AccountEntity['id']) => const onSyncAndDownload = (id?: AccountEntity['id']) =>
syncAndDownload({ id }); syncAndDownload({ id });
const createPayee = useCreatePayeeMutation(); const { mutateAsync: createPayeeAsync } = useCreatePayeeMutation();
const onCreatePayee = (name: PayeeEntity['name']) => const onCreatePayee = (name: PayeeEntity['name']) =>
createPayee.mutateAsync({ name }); createPayeeAsync({ name });
const { mutateAsync: runRulesAsync } = useRunRulesMutation();
const onRunRules = (transaction: TransactionEntity) =>
runRulesAsync({ transaction });
return ( return (
<SchedulesProvider query={schedulesQuery}> <SchedulesProvider query={schedulesQuery}>
@@ -2073,6 +2077,7 @@ export function Account() {
onUnlinkAccount={onUnlinkAccount} onUnlinkAccount={onUnlinkAccount}
onSyncAndDownload={onSyncAndDownload} onSyncAndDownload={onSyncAndDownload}
onCreatePayee={onCreatePayee} onCreatePayee={onCreatePayee}
onRunRules={onRunRules}
/> />
</SplitsExpandedProvider> </SplitsExpandedProvider>
</SchedulesProvider> </SchedulesProvider>

View File

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

View File

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

View File

@@ -5,7 +5,7 @@ import { styles } from '@actual-app/components/styles';
import { theme } from '@actual-app/components/theme'; import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view'; 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 * as undo from 'loot-core/platform/client/undo';
import { getNormalisedString } from 'loot-core/shared/normalisation'; import { getNormalisedString } from 'loot-core/shared/normalisation';
import { q } from 'loot-core/shared/query'; 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 { useCategories } from '@desktop-client/hooks/useCategories';
import { useNavigate } from '@desktop-client/hooks/useNavigate'; import { useNavigate } from '@desktop-client/hooks/useNavigate';
import { usePayees } from '@desktop-client/hooks/usePayees'; import { usePayees } from '@desktop-client/hooks/usePayees';
import { useRules } from '@desktop-client/hooks/useRules';
import { useSchedules } from '@desktop-client/hooks/useSchedules'; import { useSchedules } from '@desktop-client/hooks/useSchedules';
import { useUndo } from '@desktop-client/hooks/useUndo'; import { useUndo } from '@desktop-client/hooks/useUndo';
import { useUrlParam } from '@desktop-client/hooks/useUrlParam'; import { useUrlParam } from '@desktop-client/hooks/useUrlParam';
import { addNotification } from '@desktop-client/notifications/notificationsSlice'; import { useDeleteRuleMutation } from '@desktop-client/rules';
import { useDispatch } from '@desktop-client/redux';
export function MobileRulesPage() { export function MobileRulesPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const dispatch = useDispatch();
const { showUndoNotification } = useUndo(); const { showUndoNotification } = useUndo();
const [visibleRulesParam] = useUrlParam('visible-rules'); const [visibleRulesParam] = useUrlParam('visible-rules');
const [allRules, setAllRules] = useState<RuleEntity[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [filter, setFilter] = useState(''); const [filter, setFilter] = useState('');
const {
data: allRules = [],
isLoading: isRulesLoading,
refetch: refetchRules,
} = useRules();
const { schedules = [] } = useSchedules({ const { schedules = [] } = useSchedules({
query: useMemo(() => q('schedules').select('*'), []), query: useMemo(() => q('schedules').select('*'), []),
}); });
@@ -79,28 +81,10 @@ export function MobileRulesPage() {
); );
}, [visibleRules, filter, filterData, schedules]); }, [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 // Listen for undo events to refresh rules list
useEffect(() => { useEffect(() => {
const onUndo = () => { const onUndo = () => {
void loadRules(); void refetchRules();
}; };
const lastUndoEvent = undo.getUndoState('undoEvent'); const lastUndoEvent = undo.getUndoState('undoEvent');
@@ -109,7 +93,7 @@ export function MobileRulesPage() {
} }
return listen('undo-event', onUndo); return listen('undo-event', onUndo);
}, [loadRules]); }, [refetchRules]);
const handleRulePress = useCallback( const handleRulePress = useCallback(
(rule: RuleEntity) => { (rule: RuleEntity) => {
@@ -125,45 +109,22 @@ export function MobileRulesPage() {
[setFilter], [setFilter],
); );
const { mutate: deleteRule } = useDeleteRuleMutation();
const handleRuleDelete = useCallback( const handleRuleDelete = useCallback(
async (rule: RuleEntity) => { (rule: RuleEntity) => {
try { deleteRule(
const { someDeletionsFailed } = await send('rule-delete-all', [ { id: rule.id },
rule.id, {
]); onSuccess: () => {
showUndoNotification({
if (someDeletionsFailed) { message: t('Rule deleted successfully'),
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.'),
},
}),
);
}
}, },
[dispatch, showUndoNotification, t, loadRules], [deleteRule, showUndoNotification, t],
); );
return ( return (
@@ -199,7 +160,7 @@ export function MobileRulesPage() {
</View> </View>
<RulesList <RulesList
rules={filteredRules} rules={filteredRules}
isLoading={isLoading} isLoading={isRulesLoading}
onRulePress={handleRulePress} onRulePress={handleRulePress}
onRuleDelete={handleRuleDelete} 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 { send, sendCatch } from 'loot-core/platform/client/connection';
import * as monthUtils from 'loot-core/shared/months'; import * as monthUtils from 'loot-core/shared/months';
import { q } from 'loot-core/shared/query'; 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 { MobileBackButton } from '@desktop-client/components/mobile/MobileBackButton';
import { MobilePageHeader, Page } from '@desktop-client/components/Page'; import { MobilePageHeader, Page } from '@desktop-client/components/Page';

View File

@@ -75,7 +75,6 @@ import {
} from '@desktop-client/components/mobile/MobileForms'; } from '@desktop-client/components/mobile/MobileForms';
import { getPrettyPayee } from '@desktop-client/components/mobile/utils'; import { getPrettyPayee } from '@desktop-client/components/mobile/utils';
import { MobilePageHeader, Page } from '@desktop-client/components/Page'; 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 { AmountInput } from '@desktop-client/components/util/AmountInput';
import { useAccounts } from '@desktop-client/hooks/useAccounts'; import { useAccounts } from '@desktop-client/hooks/useAccounts';
import { useCategories } from '@desktop-client/hooks/useCategories'; import { useCategories } from '@desktop-client/hooks/useCategories';
@@ -97,6 +96,10 @@ import { useSavePayeeLocationMutation } from '@desktop-client/payees';
import { locationService } from '@desktop-client/payees/location'; import { locationService } from '@desktop-client/payees/location';
import { aqlQuery } from '@desktop-client/queries/aqlQuery'; import { aqlQuery } from '@desktop-client/queries/aqlQuery';
import { useDispatch, useSelector } from '@desktop-client/redux'; import { useDispatch, useSelector } from '@desktop-client/redux';
import {
useCreateSingleTimeScheduleFromTransaction,
useRunRulesMutation,
} from '@desktop-client/rules';
import { setLastTransaction } from '@desktop-client/transactions/transactionsSlice'; import { setLastTransaction } from '@desktop-client/transactions/transactionsSlice';
function getFieldName(transactionId: TransactionEntity['id'], field: string) { function getFieldName(transactionId: TransactionEntity['id'], field: string) {
@@ -686,6 +689,9 @@ const TransactionEditInner = memo<TransactionEditInnerProps>(
[categories, isBudgetTransfer, t], [categories, isBudgetTransfer, t],
); );
const { mutate: createSingleTimeScheduleFromTransaction } =
useCreateSingleTimeScheduleFromTransaction();
const onSaveInner = useCallback(() => { const onSaveInner = useCallback(() => {
const [unserializedTransaction] = unserializedTransactions; const [unserializedTransaction] = unserializedTransactions;
@@ -744,19 +750,24 @@ const TransactionEditInner = memo<TransactionEditInnerProps>(
} }
: unserializedTransaction; : unserializedTransaction;
await createSingleTimeScheduleFromTransaction( createSingleTimeScheduleFromTransaction(
transactionForSchedule, {
); transaction: transactionForSchedule,
},
dispatch( {
addNotification({ onSuccess: () => {
notification: { dispatch(
type: 'message', addNotification({
message: t('Schedule created successfully'), notification: {
type: 'message',
message: t('Schedule created successfully'),
},
}),
);
void navigate(-1);
}, },
}), },
); );
void navigate(-1);
}, },
onCancel: onConfirmSave, onCancel: onConfirmSave,
}, },
@@ -793,6 +804,7 @@ const TransactionEditInner = memo<TransactionEditInnerProps>(
unserializedTransactions, unserializedTransactions,
upcomingLength, upcomingLength,
t, t,
createSingleTimeScheduleFromTransaction,
]); ]);
const onUpdateInner = useCallback( const onUpdateInner = useCallback(
@@ -1484,6 +1496,8 @@ function TransactionEditUnconnected({
searchParams, searchParams,
]); ]);
const { mutateAsync: runRulesAsync } = useRunRulesMutation();
const onUpdate = useCallback( const onUpdate = useCallback(
async ( async (
serializedTransaction: TransactionEntity, serializedTransaction: TransactionEntity,
@@ -1499,9 +1513,7 @@ function TransactionEditUnconnected({
// this on new transactions because that's how desktop works. // this on new transactions because that's how desktop works.
const newTransaction = { ...transaction }; const newTransaction = { ...transaction };
if (isTemporary(newTransaction)) { if (isTemporary(newTransaction)) {
const afterRules = await send('rules-run', { const afterRules = await runRulesAsync({ transaction: newTransaction });
transaction: newTransaction,
});
const diff = getChangedValues(newTransaction, afterRules); const diff = getChangedValues(newTransaction, afterRules);
if (diff) { if (diff) {
@@ -1570,7 +1582,7 @@ function TransactionEditUnconnected({
} }
} }
}, },
[dateFormat, transactions, locationAccess], [dateFormat, transactions, locationAccess, runRulesAsync],
); );
const onSave = useCallback( const onSave = useCallback(

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,7 +8,6 @@ import { theme } from '@actual-app/components/theme';
import { send } from 'loot-core/platform/client/connection'; import { send } from 'loot-core/platform/client/connection';
import * as monthUtils from 'loot-core/shared/months'; import * as monthUtils from 'loot-core/shared/months';
import { q } from 'loot-core/shared/query';
import { getUpcomingDays } from 'loot-core/shared/schedules'; import { getUpcomingDays } from 'loot-core/shared/schedules';
import { import {
addSplitTransaction, addSplitTransaction,
@@ -23,7 +22,6 @@ import type {
AccountEntity, AccountEntity,
CategoryEntity, CategoryEntity,
PayeeEntity, PayeeEntity,
RuleActionEntity,
RuleConditionEntity, RuleConditionEntity,
ScheduleEntity, ScheduleEntity,
TransactionEntity, TransactionEntity,
@@ -41,6 +39,11 @@ import { useSyncedPref } from '@desktop-client/hooks/useSyncedPref';
import { pushModal } from '@desktop-client/modals/modalsSlice'; import { pushModal } from '@desktop-client/modals/modalsSlice';
import { addNotification } from '@desktop-client/notifications/notificationsSlice'; import { addNotification } from '@desktop-client/notifications/notificationsSlice';
import { useDispatch } from '@desktop-client/redux'; import { useDispatch } from '@desktop-client/redux';
import {
useCreateSingleTimeScheduleFromTransaction,
useRunRulesMutation,
} from '@desktop-client/rules';
// When data changes, there are two ways to update the UI: // When data changes, there are two ways to update the UI:
// //
// * Optimistic updates: we apply the needed updates to local data // * Optimistic updates: we apply the needed updates to local data
@@ -84,133 +87,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 { function isFutureTransaction(transaction: TransactionEntity): boolean {
const today = monthUtils.currentDay(); const today = monthUtils.currentDay();
return transaction.date > today; return transaction.date > today;
@@ -381,6 +257,9 @@ export function TransactionList({
[dispatch, onRefetch, upcomingLength, t], [dispatch, onRefetch, upcomingLength, t],
); );
const { mutateAsync: createSingleTimeScheduleFromTransactionAsync } =
useCreateSingleTimeScheduleFromTransaction();
const onAdd = useCallback( const onAdd = useCallback(
async (newTransactions: TransactionEntity[]) => { async (newTransactions: TransactionEntity[]) => {
newTransactions = realizeTempTransactions(newTransactions); newTransactions = realizeTempTransactions(newTransactions);
@@ -403,9 +282,9 @@ export function TransactionList({
promptToConvertToSchedule( promptToConvertToSchedule(
transactionWithSubtransactions, transactionWithSubtransactions,
async () => { async () => {
await createSingleTimeScheduleFromTransaction( await createSingleTimeScheduleFromTransactionAsync({
transactionWithSubtransactions, transaction: transactionWithSubtransactions,
); });
}, },
async () => { async () => {
await saveDiff( await saveDiff(
@@ -420,7 +299,12 @@ export function TransactionList({
await saveDiff({ added: newTransactions }, isLearnCategoriesEnabled); await saveDiff({ added: newTransactions }, isLearnCategoriesEnabled);
onRefetch(); onRefetch();
}, },
[isLearnCategoriesEnabled, onRefetch, promptToConvertToSchedule], [
isLearnCategoriesEnabled,
onRefetch,
promptToConvertToSchedule,
createSingleTimeScheduleFromTransactionAsync,
],
); );
const onSave = useCallback( const onSave = useCallback(
@@ -466,7 +350,9 @@ export function TransactionList({
await send('transaction-delete', { id: transaction.id }); await send('transaction-delete', { id: transaction.id });
} }
await createSingleTimeScheduleFromTransaction(transaction); await createSingleTimeScheduleFromTransactionAsync({
transaction,
});
}, },
saveTransaction, saveTransaction,
); );
@@ -476,7 +362,13 @@ export function TransactionList({
await saveTransaction(); await saveTransaction();
}, },
[isLearnCategoriesEnabled, onChange, onRefetch, promptToConvertToSchedule], [
isLearnCategoriesEnabled,
onChange,
onRefetch,
promptToConvertToSchedule,
createSingleTimeScheduleFromTransactionAsync,
],
); );
const onAddSplit = useCallback( const onAddSplit = useCallback(
@@ -509,12 +401,14 @@ export function TransactionList({
[isLearnCategoriesEnabled, onChange], [isLearnCategoriesEnabled, onChange],
); );
const { mutateAsync: runRulesAsync } = useRunRulesMutation();
const onApplyRules = useCallback( const onApplyRules = useCallback(
async ( async (
transaction: TransactionEntity, transaction: TransactionEntity,
updatedFieldName: string | null = null, updatedFieldName: string | null = null,
) => { ) => {
const afterRules = await send('rules-run', { transaction }); const afterRules = await runRulesAsync({ transaction });
// Show formula errors if any // Show formula errors if any
if (afterRules._ruleErrors && afterRules._ruleErrors.length > 0) { if (afterRules._ruleErrors && afterRules._ruleErrors.length > 0) {
@@ -562,7 +456,7 @@ export function TransactionList({
} }
return newTransaction; return newTransaction;
}, },
[dispatch], [dispatch, runRulesAsync],
); );
const onManagePayees = useCallback( const onManagePayees = useCallback(

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, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { send } from 'loot-core/platform/client/connection';
import { computeSchedulePreviewTransactions } from 'loot-core/shared/schedules'; import { computeSchedulePreviewTransactions } from 'loot-core/shared/schedules';
import { ungroupTransactions } from 'loot-core/shared/transactions'; import { ungroupTransactions } from 'loot-core/shared/transactions';
import type { IntegerAmount } from 'loot-core/shared/util'; import type { IntegerAmount } from 'loot-core/shared/util';
@@ -10,6 +9,8 @@ import { useCachedSchedules } from './useCachedSchedules';
import { useSyncedPref } from './useSyncedPref'; import { useSyncedPref } from './useSyncedPref';
import { calculateRunningBalancesBottomUp } from './useTransactions'; import { calculateRunningBalancesBottomUp } from './useTransactions';
import { useRunRulesMutation } from '@desktop-client/rules/mutations';
type UsePreviewTransactionsProps = { type UsePreviewTransactionsProps = {
filter?: (schedule: ScheduleEntity) => boolean; filter?: (schedule: ScheduleEntity) => boolean;
options?: { options?: {
@@ -63,6 +64,8 @@ export function usePreviewTransactions({
); );
}, [filter, isSchedulesLoading, schedules, statuses, upcomingLength]); }, [filter, isSchedulesLoading, schedules, statuses, upcomingLength]);
const { mutateAsync: runRulesAsync } = useRunRulesMutation();
useEffect(() => { useEffect(() => {
let isUnmounted = false; let isUnmounted = false;
@@ -79,7 +82,7 @@ export function usePreviewTransactions({
Promise.all( Promise.all(
scheduleTransactions.map(transaction => scheduleTransactions.map(transaction =>
// Kick off an async rules application // Kick off an async rules application
send('rules-run', { transaction }), runRulesAsync({ transaction }),
), ),
) )
.then(newTrans => { .then(newTrans => {
@@ -113,7 +116,13 @@ export function usePreviewTransactions({
return () => { return () => {
isUnmounted = true; isUnmounted = true;
}; };
}, [scheduleTransactions, schedules, statuses, upcomingLength]); }, [
scheduleTransactions,
schedules,
statuses,
upcomingLength,
runRulesAsync,
]);
const runningBalances = useMemo(() => { const runningBalances = useMemo(() => {
if (!options?.calculateRunningBalances) { if (!options?.calculateRunningBalances) {

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

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

View File

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

View File

@@ -17,6 +17,7 @@ import {
recurConfigToRSchedule, recurConfigToRSchedule,
} from '../../shared/schedules'; } from '../../shared/schedules';
import type { RuleConditionEntity, ScheduleEntity } from '../../types/models'; import type { RuleConditionEntity, ScheduleEntity } from '../../types/models';
import type { WithRequired } from '../../types/util';
import { addTransactions } from '../accounts/sync'; import { addTransactions } from '../accounts/sync';
import { createApp } from '../app'; import { createApp } from '../app';
import { aqlQuery } from '../aql'; import { aqlQuery } from '../aql';
@@ -252,10 +253,13 @@ async function checkIfScheduleExists(name, scheduleId) {
} }
export async function createSchedule({ export async function createSchedule({
schedule = null, schedule = {},
conditions = [], 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); const { date: dateCond } = extractScheduleConds(conditions);
if (dateCond == null) { if (dateCond == null) {
@@ -267,14 +271,12 @@ export async function createSchedule({
const nextDate = getNextDate(dateCond); const nextDate = getNextDate(dateCond);
const nextDateRepr = nextDate ? toDateRepr(nextDate) : null; const nextDateRepr = nextDate ? toDateRepr(nextDate) : null;
if (schedule) { if (schedule.name) {
if (schedule.name) { if (await checkIfScheduleExists(schedule.name, scheduleId)) {
if (await checkIfScheduleExists(schedule.name, scheduleId)) { throw new Error('Cannot create schedules with the same name');
throw new Error('Cannot create schedules with the same name');
}
} else {
schedule.name = null;
} }
} else {
schedule.name = null;
} }
// Create the rule here based on the info // Create the rule here based on the info
@@ -310,7 +312,7 @@ export async function updateSchedule({
conditions, conditions,
resetNextDate, resetNextDate,
}: { }: {
schedule: Partial<ScheduleEntity> & Pick<ScheduleEntity, 'id'>; schedule: WithRequired<Partial<ScheduleEntity>, 'id'>;
conditions?: RuleConditionEntity[]; conditions?: RuleConditionEntity[];
resetNextDate?: boolean; resetNextDate?: boolean;
}) { }) {

View File

@@ -13,6 +13,7 @@ import { getApproxNumberThreshold, sortNumbers } from '../../shared/rules';
import { ungroupTransaction } from '../../shared/transactions'; import { ungroupTransaction } from '../../shared/transactions';
import { fastSetMerge, partitionByField } from '../../shared/util'; import { fastSetMerge, partitionByField } from '../../shared/util';
import type { import type {
PayeeEntity,
RuleActionEntity, RuleActionEntity,
RuleEntity, RuleEntity,
TransactionEntity, TransactionEntity,
@@ -784,7 +785,10 @@ function* getOneOfSetterRules(
return null; 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', { const renameRule = getOneOfSetterRules('pre', 'imported_payee', 'payee', {
actionValue: to, actionValue: to,
}).next().value; }).next().value;

View File

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

View File

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

View File

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