mirror of
https://github.com/actualbudget/actual.git
synced 2026-05-12 10:02:28 -05:00
Compare commits
11 Commits
master
...
react-quer
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4fae08e07b | ||
|
|
72be07e29b | ||
|
|
1af1591da3 | ||
|
|
51b75df429 | ||
|
|
c21f85a399 | ||
|
|
047fa3c6c5 | ||
|
|
8c190dc480 | ||
|
|
b288ce5708 | ||
|
|
8630a4fda6 | ||
|
|
2cc9daf50a | ||
|
|
fbc1025c2b |
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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.'),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 = (
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
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, 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) {
|
||||||
|
|||||||
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,
|
||||||
|
}),
|
||||||
|
};
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}) {
|
}) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
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