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.