diff --git a/packages/desktop-client/src/components/FinancesApp.tsx b/packages/desktop-client/src/components/FinancesApp.tsx
index 26f3852e41b..c3e82793c0b 100644
--- a/packages/desktop-client/src/components/FinancesApp.tsx
+++ b/packages/desktop-client/src/components/FinancesApp.tsx
@@ -274,6 +274,9 @@ function FinancesApp() {
}
/>
+
+ {/* redirect all other traffic to the budget page */}
+ } />
diff --git a/packages/desktop-client/src/components/budget/BudgetMonthCountContext.js b/packages/desktop-client/src/components/budget/BudgetMonthCountContext.js
deleted file mode 100644
index 4fda12ed7b2..00000000000
--- a/packages/desktop-client/src/components/budget/BudgetMonthCountContext.js
+++ /dev/null
@@ -1,17 +0,0 @@
-import React, { createContext, useContext, useState } from 'react';
-
-let BudgetMonthCountContext = createContext();
-
-export function BudgetMonthCountProvider({ children }) {
- let [displayMax, setDisplayMax] = useState(1);
-
- return (
-
- {children}
-
- );
-}
-
-export function useBudgetMonthCount() {
- return useContext(BudgetMonthCountContext);
-}
diff --git a/packages/desktop-client/src/components/budget/BudgetMonthCountContext.tsx b/packages/desktop-client/src/components/budget/BudgetMonthCountContext.tsx
new file mode 100644
index 00000000000..4a825f4ec20
--- /dev/null
+++ b/packages/desktop-client/src/components/budget/BudgetMonthCountContext.tsx
@@ -0,0 +1,35 @@
+import React, {
+ createContext,
+ type Dispatch,
+ type ReactNode,
+ type SetStateAction,
+ useContext,
+ useState,
+} from 'react';
+
+type BudgetMonthCountContextValue = {
+ displayMax: number;
+ setDisplayMax: Dispatch>;
+};
+
+let BudgetMonthCountContext = createContext(null);
+
+type BudgetMonthCountProviderProps = {
+ children: ReactNode;
+};
+
+export function BudgetMonthCountProvider({
+ children,
+}: BudgetMonthCountProviderProps) {
+ let [displayMax, setDisplayMax] = useState(1);
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useBudgetMonthCount() {
+ return useContext(BudgetMonthCountContext);
+}
diff --git a/packages/desktop-client/src/components/common/Button.tsx b/packages/desktop-client/src/components/common/Button.tsx
index a619b4d1946..e568196b4dd 100644
--- a/packages/desktop-client/src/components/common/Button.tsx
+++ b/packages/desktop-client/src/components/common/Button.tsx
@@ -22,7 +22,7 @@ type ButtonProps = HTMLProps & {
as?: ElementType;
};
-type ButtonType = 'normal' | 'primary' | 'bare';
+type ButtonType = 'normal' | 'primary' | 'bare' | 'link';
const backgroundColor = {
normal: theme.buttonNormalBackground,
@@ -33,6 +33,7 @@ const backgroundColor = {
bareDisabled: theme.buttonBareDisabledBackground,
menu: theme.buttonMenuBackground,
menuSelected: theme.buttonMenuSelectedBackground,
+ link: theme.buttonBareBackground,
};
const backgroundColorHover = {
@@ -41,6 +42,7 @@ const backgroundColorHover = {
bare: theme.buttonBareBackgroundHover,
menu: theme.buttonMenuBackgroundHover,
menuSelected: theme.buttonMenuSelectedBackgroundHover,
+ link: theme.buttonBareBackground,
};
const borderColor = {
@@ -50,6 +52,7 @@ const borderColor = {
primaryDisabled: theme.buttonPrimaryDisabledBorder,
menu: theme.buttonMenuBorder,
menuSelected: theme.buttonMenuSelectedBorder,
+ link: theme.buttonBareBackground,
};
const textColor = {
@@ -61,6 +64,7 @@ const textColor = {
bareDisabled: theme.buttonBareDisabledText,
menu: theme.buttonMenuText,
menuSelected: theme.buttonMenuSelectedText,
+ link: theme.pageTextLink,
};
const textColorHover = {
@@ -71,6 +75,55 @@ const textColorHover = {
menuSelected: theme.buttonMenuSelectedTextHover,
};
+const linkButtonHoverStyles = {
+ textDecoration: 'underline',
+ boxShadow: 'none',
+};
+
+const _getBorder = (type, typeWithDisabled) => {
+ switch (type) {
+ case 'bare':
+ case 'link':
+ return 'none';
+
+ default:
+ return '1px solid ' + borderColor[typeWithDisabled];
+ }
+};
+
+const _getPadding = type => {
+ switch (type) {
+ case 'bare':
+ return '5px';
+ case 'link':
+ return '0';
+ default:
+ return '5px 10px';
+ }
+};
+
+const _getActiveStyles = (type, bounce) => {
+ switch (type) {
+ case 'bare':
+ return { backgroundColor: theme.buttonBareBackgroundActive };
+ case 'link':
+ return {
+ transform: 'none',
+ boxShadow: 'none',
+ };
+ default:
+ return {
+ transform: bounce && 'translateY(1px)',
+ boxShadow: `0 1px 4px 0 ${
+ type === 'primary'
+ ? theme.buttonPrimaryShadow
+ : theme.buttonNormalShadow
+ }`,
+ transition: 'none',
+ };
+ }
+};
+
const Button = forwardRef(
(
{
@@ -94,22 +147,13 @@ const Button = forwardRef(
hoveredStyle = {
...(type !== 'bare' && styles.shadow),
+ ...(type === 'link' && linkButtonHoverStyles),
backgroundColor: backgroundColorHover[type],
color: color || textColorHover[type],
...hoveredStyle,
};
activeStyle = {
- ...(type === 'bare'
- ? { backgroundColor: theme.buttonBareBackgroundActive }
- : {
- transform: bounce && 'translateY(1px)',
- boxShadow:
- '0 1px 4px 0 ' +
- (type === 'primary'
- ? theme.buttonPrimaryShadow
- : theme.buttonNormalShadow),
- transition: 'none',
- }),
+ ..._getActiveStyles(type, bounce),
...activeStyle,
};
@@ -118,14 +162,13 @@ const Button = forwardRef(
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
- padding: type === 'bare' ? '5px' : '5px 10px',
+ padding: _getPadding(type),
margin: 0,
overflow: 'hidden',
- display: 'flex',
+ display: type === 'link' ? 'inline' : 'flex',
borderRadius: 4,
backgroundColor: backgroundColor[typeWithDisabled],
- border:
- type === 'bare' ? 'none' : '1px solid ' + borderColor[typeWithDisabled],
+ border: _getBorder(type, typeWithDisabled),
color: color || textColor[typeWithDisabled],
transition: 'box-shadow .25s',
WebkitAppRegion: 'no-drag',
diff --git a/packages/desktop-client/src/components/reports/CashFlow.js b/packages/desktop-client/src/components/reports/CashFlow.js
index 704d6e99524..5869b7083e0 100644
--- a/packages/desktop-client/src/components/reports/CashFlow.js
+++ b/packages/desktop-client/src/components/reports/CashFlow.js
@@ -33,7 +33,7 @@ function CashFlow() {
const [allMonths, setAllMonths] = useState(null);
const [start, setStart] = useState(
- monthUtils.subMonths(monthUtils.currentMonth(), 30),
+ monthUtils.subMonths(monthUtils.currentMonth(), 5),
);
const [end, setEnd] = useState(monthUtils.currentDay());
diff --git a/packages/desktop-client/src/components/rules/ActionExpression.tsx b/packages/desktop-client/src/components/rules/ActionExpression.tsx
index 0b6cc17877c..a320c79d933 100644
--- a/packages/desktop-client/src/components/rules/ActionExpression.tsx
+++ b/packages/desktop-client/src/components/rules/ActionExpression.tsx
@@ -1,7 +1,11 @@
import React from 'react';
import { mapField, friendlyOp } from 'loot-core/src/shared/rules';
-import { type ScheduleEntity } from 'loot-core/src/types/models';
+import {
+ type LinkScheduleRuleActionEntity,
+ type RuleActionEntity,
+ type SetRuleActionEntity,
+} from 'loot-core/src/types/models';
import { type CSSProperties, theme } from '../../style';
import Text from '../common/Text';
@@ -14,20 +18,13 @@ let valueStyle = {
color: theme.pageTextPositive,
};
-type ActionExpressionProps = {
- field: unknown;
- op: unknown;
- value: unknown;
- options: unknown;
+type ActionExpressionProps = RuleActionEntity & {
style?: CSSProperties;
};
export default function ActionExpression({
- field,
- op,
- value,
- options,
style,
+ ...props
}: ActionExpressionProps) {
return (
- {op === 'set' ? (
- <>
- {friendlyOp(op)}{' '}
- {mapField(field, options)}{' '}
- to
-
- >
- ) : op === 'link-schedule' ? (
- <>
- {friendlyOp(op)}{' '}
-
- >
+ {props.op === 'set' ? (
+
+ ) : props.op === 'link-schedule' ? (
+
) : null}
);
}
+
+function SetActionExpression({
+ op,
+ field,
+ value,
+ options,
+}: SetRuleActionEntity) {
+ return (
+ <>
+ {friendlyOp(op)}{' '}
+ {mapField(field, options)}{' '}
+ to
+
+ >
+ );
+}
+
+function LinkScheduleActionExpression({
+ op,
+ value,
+}: LinkScheduleRuleActionEntity) {
+ return (
+ <>
+ {friendlyOp(op)}
+ >
+ );
+}
diff --git a/packages/desktop-client/src/components/rules/RuleRow.tsx b/packages/desktop-client/src/components/rules/RuleRow.tsx
index 9199a68ed3e..037a85cba91 100644
--- a/packages/desktop-client/src/components/rules/RuleRow.tsx
+++ b/packages/desktop-client/src/components/rules/RuleRow.tsx
@@ -105,10 +105,7 @@ const RuleRow = memo(
{rule.actions.map((action, i) => (
))}
diff --git a/packages/desktop-client/src/components/settings/UI.tsx b/packages/desktop-client/src/components/settings/UI.tsx
index 1a402de8f82..766e4952f68 100644
--- a/packages/desktop-client/src/components/settings/UI.tsx
+++ b/packages/desktop-client/src/components/settings/UI.tsx
@@ -5,7 +5,7 @@ import { css, media } from 'glamor';
import { type CSSProperties, theme } from '../../style';
import tokens from '../../tokens';
-import LinkButton from '../common/LinkButton';
+import Button from '../common/Button';
import View from '../common/View';
type SettingProps = {
@@ -77,8 +77,9 @@ export const AdvancedToggle = ({ children }: AdvancedToggleProps) => {
{children}
) : (
- setExpanded(true)}
style={{
flexShrink: 0,
@@ -88,6 +89,6 @@ export const AdvancedToggle = ({ children }: AdvancedToggleProps) => {
}}
>
Show advanced settings
-
+
);
};
diff --git a/packages/loot-core/src/server/accounts/rules.ts b/packages/loot-core/src/server/accounts/rules.ts
index 635f508e1f7..2d09078872b 100644
--- a/packages/loot-core/src/server/accounts/rules.ts
+++ b/packages/loot-core/src/server/accounts/rules.ts
@@ -131,7 +131,7 @@ let CONDITION_TYPES = {
},
string: {
ops: ['is', 'contains', 'oneOf', 'isNot', 'doesNotContain', 'notOneOf'],
- nullable: false,
+ nullable: true,
parse(op, value, fieldName) {
if (op === 'oneOf' || op === 'notOneOf') {
assert(
diff --git a/packages/loot-core/src/server/accounts/transaction-rules.test.ts b/packages/loot-core/src/server/accounts/transaction-rules.test.ts
index 50211264f6e..9827e5cd2c6 100644
--- a/packages/loot-core/src/server/accounts/transaction-rules.test.ts
+++ b/packages/loot-core/src/server/accounts/transaction-rules.test.ts
@@ -402,6 +402,22 @@ describe('Transaction rules', () => {
notes: 'FooO',
amount: -322,
});
+ await db.insertTransaction({
+ id: '4',
+ date: '2020-10-16',
+ account,
+ payee: lowesId,
+ notes: null,
+ amount: 101,
+ });
+ await db.insertTransaction({
+ id: '5',
+ date: '2020-10-16',
+ account,
+ payee: lowesId,
+ notes: '',
+ amount: 124,
+ });
let transactions = await getMatchingTransactions([
{ field: 'date', op: 'is', value: '2020-10-15' },
@@ -411,7 +427,7 @@ describe('Transaction rules', () => {
transactions = await getMatchingTransactions([
{ field: 'payee', op: 'is', value: lowesId },
]);
- expect(transactions.map(t => t.id)).toEqual(['3']);
+ expect(transactions.map(t => t.id)).toEqual(['4', '5', '3']);
transactions = await getMatchingTransactions([
{ field: 'amount', op: 'is', value: 353 },
@@ -433,6 +449,11 @@ describe('Transaction rules', () => {
]);
expect(transactions.map(t => t.id)).toEqual(['2', '3', '1']);
+ transactions = await getMatchingTransactions([
+ { field: 'notes', op: 'is', value: '' },
+ ]);
+ expect(transactions.map(t => t.id)).toEqual(['4', '5']);
+
transactions = await getMatchingTransactions([
{ field: 'amount', op: 'gt', value: 300 },
]);
@@ -454,7 +475,7 @@ describe('Transaction rules', () => {
transactions = await getMatchingTransactions([
{ field: 'amount', op: 'gt', value: -1000, options: { inflow: true } },
]);
- expect(transactions.map(t => t.id)).toEqual(['2', '1']);
+ expect(transactions.map(t => t.id)).toEqual(['4', '5', '2', '1']);
// Same thing for `outflow`: never return `inflow` transactions
transactions = await getMatchingTransactions([
@@ -465,7 +486,7 @@ describe('Transaction rules', () => {
transactions = await getMatchingTransactions([
{ field: 'date', op: 'gt', value: '2020-10-10' },
]);
- expect(transactions.map(t => t.id)).toEqual(['2', '3']);
+ expect(transactions.map(t => t.id)).toEqual(['4', '5', '2', '3']);
// todo: isapprox
});
diff --git a/packages/loot-core/src/server/accounts/transaction-rules.ts b/packages/loot-core/src/server/accounts/transaction-rules.ts
index 56cc6a73ae3..4761375a32a 100644
--- a/packages/loot-core/src/server/accounts/transaction-rules.ts
+++ b/packages/loot-core/src/server/accounts/transaction-rules.ts
@@ -11,6 +11,7 @@ import {
getApproxNumberThreshold,
} from '../../shared/rules';
import { partitionByField, fastSetMerge } from '../../shared/util';
+import { type RuleActionEntity, type RuleEntity } from '../../types/models';
import { schemaConfig } from '../aql';
import * as db from '../db';
import { getMappings } from '../db/mappings';
@@ -27,6 +28,7 @@ import {
migrateIds,
iterateIds,
} from './rules';
+import { batchUpdateTransactions } from './transactions';
// TODO: Detect if it looks like the user is creating a rename rule
// and prompt to create it in the pre phase instead
@@ -202,7 +204,9 @@ export function getRules() {
return [...allRules.values()];
}
-export async function insertRule(rule) {
+export async function insertRule(
+ rule: Omit & { id?: string },
+) {
rule = ruleModel.validate(rule);
return db.insertWithUUID('rules', ruleModel.fromJS(rule));
}
@@ -212,7 +216,7 @@ export async function updateRule(rule) {
return db.update('rules', ruleModel.fromJS(rule));
}
-export async function deleteRule(rule) {
+export async function deleteRule(rule: T) {
let schedule = await db.first('SELECT id FROM schedules WHERE rule = ?', [
rule.id,
]);
@@ -415,6 +419,12 @@ export function conditionsToAQL(conditions, { recurDateBounds = 100 } = {}) {
};
}
return apply(field, '$eq', number);
+ } else if (type === 'string') {
+ if (value === '') {
+ return {
+ $or: [apply(field, '$eq', null), apply(field, '$eq', '')],
+ };
+ }
}
return apply(field, '$eq', value);
case 'isNot':
@@ -477,7 +487,10 @@ export function conditionsToAQL(conditions, { recurDateBounds = 100 } = {}) {
return { filters, errors };
}
-export function applyActions(transactionIds, actions, handlers) {
+export function applyActions(
+ transactionIds: string[],
+ actions: Array,
+) {
let parsedActions = actions
.map(action => {
if (action instanceof Action) {
@@ -485,6 +498,10 @@ export function applyActions(transactionIds, actions, handlers) {
}
try {
+ if (action.op === 'link-schedule') {
+ return new Action(action.op, null, action.value, null, FIELD_TYPES);
+ }
+
return new Action(
action.op,
action.field,
@@ -512,7 +529,7 @@ export function applyActions(transactionIds, actions, handlers) {
return update;
});
- return handlers['transactions-batch-update']({ updated });
+ return batchUpdateTransactions({ updated });
}
export function getRulesForPayee(payeeId) {
@@ -583,7 +600,7 @@ function* getOneOfSetterRules(
return null;
}
-export async function updatePayeeRenameRule(fromNames, to) {
+export async function updatePayeeRenameRule(fromNames: string[], to: string) {
let renameRule = getOneOfSetterRules('pre', 'imported_payee', 'payee', {
actionValue: to,
}).next().value;
diff --git a/packages/loot-core/src/server/accounts/transactions.ts b/packages/loot-core/src/server/accounts/transactions.ts
index c57b2972430..ed1591290e9 100644
--- a/packages/loot-core/src/server/accounts/transactions.ts
+++ b/packages/loot-core/src/server/accounts/transactions.ts
@@ -51,7 +51,7 @@ export async function batchUpdateTransactions({
}>;
learnCategories?: boolean;
detectOrphanPayees?: boolean;
-}): Promise<{ added: TransactionEntity[]; updated: unknown[] }> {
+}) {
// Track the ids of each type of transaction change (see below for why)
let addedIds = [];
let updatedIds = updated ? updated.map(u => u.id) : [];
@@ -126,7 +126,7 @@ export async function batchUpdateTransactions({
// to the client so that can apply them. Note that added
// transactions just return the full transaction.
let resultAdded = allAdded;
- let resultUpdated: unknown[];
+ let resultUpdated: Awaited>[];
await batchMessages(async () => {
await Promise.all(allAdded.map(t => transfer.onInsert(t)));
diff --git a/packages/loot-core/src/server/errors.ts b/packages/loot-core/src/server/errors.ts
index 1450c447e66..a81a2a27bed 100644
--- a/packages/loot-core/src/server/errors.ts
+++ b/packages/loot-core/src/server/errors.ts
@@ -37,9 +37,9 @@ export class SyncError extends Error {
export class TransactionError extends Error {}
export class RuleError extends Error {
- type;
+ type: string;
- constructor(name, message) {
+ constructor(name: string, message: string) {
super('RuleError: ' + message);
this.type = name;
}
diff --git a/packages/loot-core/src/server/main.ts b/packages/loot-core/src/server/main.ts
index ac739846511..fd29a49b6a2 100644
--- a/packages/loot-core/src/server/main.ts
+++ b/packages/loot-core/src/server/main.ts
@@ -13,7 +13,6 @@ import * as sqlite from '../platform/server/sqlite';
import { isNonProductionEnvironment } from '../shared/environment';
import * as monthUtils from '../shared/months';
import q, { Query } from '../shared/query';
-import { FIELD_TYPES as ruleFieldTypes } from '../shared/rules';
import { amountToInteger, stringToInteger } from '../shared/util';
import { Handlers } from '../types/handlers';
@@ -21,7 +20,6 @@ import { exportToCSV, exportQueryToCSV } from './accounts/export-to-csv';
import * as link from './accounts/link';
import { parseFile } from './accounts/parse-file';
import { getStartingBalancePayee } from './accounts/payees';
-import { Condition, Action, rankRules } from './accounts/rules';
import * as bankSync from './accounts/sync';
import * as rules from './accounts/transaction-rules';
import { batchUpdateTransactions } from './accounts/transactions';
@@ -40,7 +38,7 @@ import * as cloudStorage from './cloud-storage';
import * as db from './db';
import * as mappings from './db/mappings';
import * as encryption from './encryption';
-import { APIError, TransactionError, PostError, RuleError } from './errors';
+import { APIError, TransactionError, PostError } from './errors';
import filtersApp from './filters/app';
import { handleBudgetImport } from './importers';
import app from './main-app';
@@ -49,6 +47,7 @@ import notesApp from './notes/app';
import * as Platform from './platform';
import { get, post } from './post';
import * as prefs from './prefs';
+import rulesApp from './rules/app';
import schedulesApp from './schedules/app';
import { getServer, setServer } from './server-config';
import * as sheet from './sheet';
@@ -499,128 +498,6 @@ handlers['payees-get-rules'] = async function ({ id }) {
return rules.getRulesForPayee(id).map(rule => rule.serialize());
};
-function validateRule(rule) {
- // Returns an array of errors, the array is the same link as the
- // passed-in `array`, or null if there are no errors
- function runValidation(array, validate) {
- let result = array.map(item => {
- try {
- validate(item);
- } catch (e) {
- if (e instanceof RuleError) {
- console.warn('Invalid rule', e);
- return e.type;
- }
- throw e;
- }
- return null;
- });
-
- return result.some(Boolean) ? result : null;
- }
-
- let conditionErrors = runValidation(
- rule.conditions,
- cond =>
- new Condition(
- cond.op,
- cond.field,
- cond.value,
- cond.options,
- ruleFieldTypes,
- ),
- );
-
- let actionErrors = runValidation(
- rule.actions,
- action =>
- new Action(
- action.op,
- action.field,
- action.value,
- action.options,
- ruleFieldTypes,
- ),
- );
-
- if (conditionErrors || actionErrors) {
- return {
- conditionErrors,
- actionErrors,
- };
- }
-
- return null;
-}
-
-handlers['rule-validate'] = async function (rule) {
- let error = validateRule(rule);
- return { error };
-};
-
-handlers['rule-add'] = mutator(async function (rule) {
- let error = validateRule(rule);
- if (error) {
- return { error };
- }
-
- let id = await rules.insertRule(rule);
- return { id };
-});
-
-handlers['rule-update'] = mutator(async function (rule) {
- let error = validateRule(rule);
- if (error) {
- return { error };
- }
-
- await rules.updateRule(rule);
- return {};
-});
-
-handlers['rule-delete'] = mutator(async function (rule) {
- return rules.deleteRule(rule);
-});
-
-handlers['rule-delete-all'] = mutator(async function (ids) {
- let someDeletionsFailed = false;
-
- await batchMessages(async () => {
- for (let id of ids) {
- let res = await rules.deleteRule({ id });
- if (res === false) {
- someDeletionsFailed = true;
- }
- }
- });
-
- return { someDeletionsFailed };
-});
-
-handlers['rule-apply-actions'] = mutator(async function ({
- transactionIds,
- actions,
-}) {
- return rules.applyActions(transactionIds, actions, handlers);
-});
-
-handlers['rule-add-payee-rename'] = mutator(async function ({ fromNames, to }) {
- return rules.updatePayeeRenameRule(fromNames, to);
-});
-
-handlers['rules-get'] = async function () {
- return rankRules(rules.getRules()).map(rule => rule.serialize());
-};
-
-handlers['rule-get'] = async function ({ id }) {
- let rule = rules.getRules().find(rule => rule.id === id);
- return rule ? rule.serialize() : null;
-};
-
-handlers['rules-run'] = async function ({ transaction }) {
- return rules.runRules(transaction);
-};
-
handlers['make-filters-from-conditions'] = async function ({ conditions }) {
return rules.conditionsToAQL(conditions);
};
@@ -2252,7 +2129,7 @@ injectAPI.override((name, args) => runHandler(app.handlers[name], args));
// A hack for now until we clean up everything
app.handlers = handlers;
-app.combine(schedulesApp, budgetApp, notesApp, toolsApp, filtersApp);
+app.combine(schedulesApp, budgetApp, notesApp, toolsApp, filtersApp, rulesApp);
function getDefaultDocumentDir() {
if (Platform.isMobile) {
diff --git a/packages/loot-core/src/server/rules/app.ts b/packages/loot-core/src/server/rules/app.ts
new file mode 100644
index 00000000000..47c0b73fb34
--- /dev/null
+++ b/packages/loot-core/src/server/rules/app.ts
@@ -0,0 +1,157 @@
+import { FIELD_TYPES as ruleFieldTypes } from '../../shared/rules';
+import { type RuleEntity } from '../../types/models';
+import { Condition, Action, rankRules } from '../accounts/rules';
+import * as rules from '../accounts/transaction-rules';
+import { createApp } from '../app';
+import { RuleError } from '../errors';
+import { mutator } from '../mutators';
+import { batchMessages } from '../sync';
+import { undoable } from '../undo';
+
+import { RulesHandlers } from './types/handlers';
+
+function validateRule(rule: Partial) {
+ // Returns an array of errors, the array is the same link as the
+ // passed-in `array`, or null if there are no errors
+ function runValidation(array: T[], validate: (item: T) => unknown) {
+ const result = array
+ .map(item => {
+ try {
+ validate(item);
+ } catch (e) {
+ if (e instanceof RuleError) {
+ console.warn('Invalid rule', e);
+ return e.type;
+ }
+ throw e;
+ }
+ return null;
+ })
+ .filter((res): res is string => typeof res === 'string');
+
+ return result.length ? result : null;
+ }
+
+ let conditionErrors = runValidation(
+ rule.conditions,
+ cond =>
+ new Condition(
+ cond.op,
+ cond.field,
+ cond.value,
+ cond.options,
+ ruleFieldTypes,
+ ),
+ );
+
+ let actionErrors = runValidation(rule.actions, action =>
+ action.op === 'link-schedule'
+ ? new Action(action.op, null, action.value, null, ruleFieldTypes)
+ : new Action(
+ action.op,
+ action.field,
+ action.value,
+ action.options,
+ ruleFieldTypes,
+ ),
+ );
+
+ if (conditionErrors || actionErrors) {
+ return {
+ conditionErrors,
+ actionErrors,
+ };
+ }
+
+ return null;
+}
+
+// Expose functions to the client
+let app = createApp();
+
+app.method('rule-validate', async function (rule) {
+ let error = validateRule(rule);
+ return { error };
+});
+
+app.method(
+ 'rule-add',
+ mutator(async function (rule) {
+ let error = validateRule(rule);
+ if (error) {
+ return { error };
+ }
+
+ let id = await rules.insertRule(rule);
+ return { id };
+ }),
+);
+
+app.method(
+ 'rule-update',
+ mutator(async function (rule) {
+ let error = validateRule(rule);
+ if (error) {
+ return { error };
+ }
+
+ await rules.updateRule(rule);
+ return {};
+ }),
+);
+
+app.method(
+ 'rule-delete',
+ mutator(async function (rule) {
+ return rules.deleteRule(rule);
+ }),
+);
+
+app.method(
+ 'rule-delete-all',
+ mutator(async function (ids) {
+ let someDeletionsFailed = false;
+
+ await batchMessages(async () => {
+ for (let id of ids) {
+ let res = await rules.deleteRule({ id });
+ if (res === false) {
+ someDeletionsFailed = true;
+ }
+ }
+ });
+
+ return { someDeletionsFailed };
+ }),
+);
+
+app.method(
+ 'rule-apply-actions',
+ mutator(
+ undoable(async function ({ transactionIds, actions }) {
+ return rules.applyActions(transactionIds, actions);
+ }),
+ ),
+);
+
+app.method(
+ 'rule-add-payee-rename',
+ mutator(async function ({ fromNames, to }) {
+ return rules.updatePayeeRenameRule(fromNames, to);
+ }),
+);
+
+app.method('rules-get', async function () {
+ return rankRules(rules.getRules()).map(rule => rule.serialize());
+});
+
+app.method('rule-get', async function ({ id }) {
+ let rule = rules.getRules().find(rule => rule.id === id);
+ return rule ? rule.serialize() : null;
+});
+
+app.method('rules-run', async function ({ transaction }) {
+ return rules.runRules(transaction);
+});
+
+export default app;
diff --git a/packages/loot-core/src/server/rules/types/handlers.ts b/packages/loot-core/src/server/rules/types/handlers.ts
new file mode 100644
index 00000000000..27e09e3e821
--- /dev/null
+++ b/packages/loot-core/src/server/rules/types/handlers.ts
@@ -0,0 +1,49 @@
+import {
+ type RuleEntity,
+ type TransactionEntity,
+ type RuleActionEntity,
+} from '../../../types/models';
+import { type Action } from '../../accounts/rules';
+
+type ValidationError = {
+ conditionErrors: string[];
+ actionErrors: string[];
+};
+
+export interface RulesHandlers {
+ 'rule-validate': (
+ rule: Partial,
+ ) => Promise<{ error: ValidationError | null }>;
+
+ 'rule-add': (
+ rule: Omit,
+ ) => Promise<{ error: ValidationError } | { id: string }>;
+
+ 'rule-update': (
+ rule: Partial,
+ ) => Promise<{ error: ValidationError } | object>;
+
+ 'rule-delete': (rule: RuleEntity) => Promise;
+
+ 'rule-delete-all': (
+ ids: string[],
+ ) => Promise<{ someDeletionsFailed: boolean }>;
+
+ 'rule-apply-actions': (arg: {
+ transactionIds: string[];
+ actions: Array;
+ }) => Promise;
+
+ 'rule-add-payee-rename': (arg: {
+ fromNames: string[];
+ to: string;
+ }) => Promise;
+
+ 'rules-get': () => Promise;
+
+ // TODO: change return value to `RuleEntity`
+ 'rule-get': (arg: { id: string }) => Promise;
+
+ // TODO: change types to `TransactionEntity`
+ 'rules-run': (arg: { transaction }) => Promise;
+}
diff --git a/packages/loot-core/src/shared/rules.ts b/packages/loot-core/src/shared/rules.ts
index 53b143d61a3..d0fa730a44a 100644
--- a/packages/loot-core/src/shared/rules.ts
+++ b/packages/loot-core/src/shared/rules.ts
@@ -17,7 +17,7 @@ export const TYPE_INFO = {
},
string: {
ops: ['is', 'contains', 'oneOf', 'isNot', 'doesNotContain', 'notOneOf'],
- nullable: false,
+ nullable: true,
},
number: {
ops: ['is', 'isapprox', 'isbetween', 'gt', 'gte', 'lt', 'lte'],
diff --git a/packages/loot-core/src/types/models/rule.d.ts b/packages/loot-core/src/types/models/rule.d.ts
index e7e79faf83c..263cd78b3c9 100644
--- a/packages/loot-core/src/types/models/rule.d.ts
+++ b/packages/loot-core/src/types/models/rule.d.ts
@@ -1,24 +1,35 @@
+import { type ScheduleEntity } from './schedule';
+
export interface RuleEntity {
id: string;
stage: string;
- conditions_op: string;
+ conditions_op?: string;
conditionsOp?: string; // TODO: this should not be here.. figure out howto remove it
conditions: RuleConditionEntity[];
actions: RuleActionEntity[];
- tombstone: boolean;
+ tombstone?: boolean;
}
interface RuleConditionEntity {
field: unknown;
op: unknown;
value: unknown;
- options: unknown;
- conditionsOp: unknown;
+ options?: unknown;
+ conditionsOp?: unknown;
}
-interface RuleActionEntity {
- field: unknown;
- op: unknown;
+export type RuleActionEntity =
+ | SetRuleActionEntity
+ | LinkScheduleRuleActionEntity;
+
+export interface SetRuleActionEntity {
+ field: string;
+ op: 'set';
value: unknown;
- options: unknown;
+ options?: unknown;
+}
+
+export interface LinkScheduleRuleActionEntity {
+ op: 'link-schedule';
+ value: ScheduleEntity;
}
diff --git a/packages/loot-core/src/types/server-handlers.d.ts b/packages/loot-core/src/types/server-handlers.d.ts
index 6cad98768bc..6d673054350 100644
--- a/packages/loot-core/src/types/server-handlers.d.ts
+++ b/packages/loot-core/src/types/server-handlers.d.ts
@@ -97,26 +97,6 @@ export interface ServerHandlers {
'payees-get-rules': (arg: { id }) => Promise;
- 'rule-validate': (rule) => Promise<{ error: unknown }>;
-
- 'rule-add': (rule) => Promise<{ error: unknown } | { id: string }>;
-
- 'rule-add': (rule) => Promise<{ error: unknown } | unknown>;
-
- 'rule-delete': (rule) => Promise;
-
- 'rule-delete-all': (ids) => Promise;
-
- 'rule-apply-actions': (arg: { transactionIds; actions }) => Promise;
-
- 'rule-add-payee-rename': (arg: { fromNames; to }) => Promise;
-
- 'rules-get': () => Promise;
-
- 'rule-get': (arg: { id }) => Promise;
-
- 'rules-run': (arg: { transaction }) => Promise;
-
'make-filters-from-conditions': (arg: {
conditions;
}) => Promise<{ filters: unknown[] }>;
diff --git a/upcoming-release-notes/1677.md b/upcoming-release-notes/1677.md
new file mode 100644
index 00000000000..1274b0652d2
--- /dev/null
+++ b/upcoming-release-notes/1677.md
@@ -0,0 +1,6 @@
+---
+category: Maintenance
+authors: [MatissJanis]
+---
+
+Moving 'rules' server action handlers into a separate file
diff --git a/upcoming-release-notes/1708.md b/upcoming-release-notes/1708.md
new file mode 100644
index 00000000000..faae4287d45
--- /dev/null
+++ b/upcoming-release-notes/1708.md
@@ -0,0 +1,6 @@
+---
+category: Bugfix
+authors: [syukronrm]
+---
+
+fixing filter transaction to show empty note instead of showing error "Value cannot be empty"
diff --git a/upcoming-release-notes/1721.md b/upcoming-release-notes/1721.md
new file mode 100644
index 00000000000..125a81abd6e
--- /dev/null
+++ b/upcoming-release-notes/1721.md
@@ -0,0 +1,6 @@
+---
+category: Bugfix
+authors: [MatissJanis]
+---
+
+Redirect back to budget page if non-existing pages accessed
diff --git a/upcoming-release-notes/1722.md b/upcoming-release-notes/1722.md
new file mode 100644
index 00000000000..5eaca206e82
--- /dev/null
+++ b/upcoming-release-notes/1722.md
@@ -0,0 +1,6 @@
+---
+category: Maintenance
+authors: [Jod929]
+---
+
+Refactor budget/BudgetMonthCountContext to tsx.
\ No newline at end of file
diff --git a/upcoming-release-notes/1723.md b/upcoming-release-notes/1723.md
new file mode 100644
index 00000000000..495f4069a69
--- /dev/null
+++ b/upcoming-release-notes/1723.md
@@ -0,0 +1,6 @@
+---
+category: Enhancements
+authors: [shaankhosla]
+---
+
+Changed the default number of months shown in the Cash Flow report from 30 to 5.
diff --git a/upcoming-release-notes/1725.md b/upcoming-release-notes/1725.md
new file mode 100644
index 00000000000..3edfab25eae
--- /dev/null
+++ b/upcoming-release-notes/1725.md
@@ -0,0 +1,6 @@
+---
+category: Maintenance
+authors: [th3c0d3br34ker]
+---
+
+Add support for type 'link' in Button component.