From ce4b80f4998709afb7e7a100c47d7b07ef967afc Mon Sep 17 00:00:00 2001 From: Koen van Staveren Date: Tue, 8 Oct 2024 18:43:04 +0200 Subject: [PATCH] feat(rules): templating actions (#3305) * feat(rules): templating actions * chore: update snapshots * fix: date functions templating * chore: lint * fix: put action templating behind feature flag * fix: template syntax checking * test: handle bar functions * chore: pr feedback * feat: add `{{debug x}}` handler --- .../src/components/modals/EditRuleModal.jsx | 49 +++++++++- .../src/components/rules/ActionExpression.tsx | 9 +- .../src/components/settings/Experimental.tsx | 6 ++ .../src/hooks/useFeatureFlag.ts | 1 + packages/loot-core/package.json | 1 + .../transaction-rules.test.ts.snap | 6 ++ .../src/server/accounts/rules.test.ts | 93 +++++++++++++++++++ .../loot-core/src/server/accounts/rules.ts | 92 +++++++++++++++++- packages/loot-core/src/shared/rules.ts | 2 + packages/loot-core/src/types/models/rule.d.ts | 1 + packages/loot-core/src/types/prefs.d.ts | 3 +- upcoming-release-notes/3305.md | 6 ++ yarn.lock | 37 +++++++- 13 files changed, 299 insertions(+), 7 deletions(-) create mode 100644 upcoming-release-notes/3305.md diff --git a/packages/desktop-client/src/components/modals/EditRuleModal.jsx b/packages/desktop-client/src/components/modals/EditRuleModal.jsx index db40fe13605..0ddd6fd8deb 100644 --- a/packages/desktop-client/src/components/modals/EditRuleModal.jsx +++ b/packages/desktop-client/src/components/modals/EditRuleModal.jsx @@ -37,9 +37,10 @@ import { } from 'loot-core/src/shared/util'; import { useDateFormat } from '../../hooks/useDateFormat'; +import { useFeatureFlag } from '../../hooks/useFeatureFlag'; import { useSelected, SelectedProvider } from '../../hooks/useSelected'; import { SvgDelete, SvgAdd, SvgSubtract } from '../../icons/v0'; -import { SvgInformationOutline } from '../../icons/v1'; +import { SvgAlignLeft, SvgCode, SvgInformationOutline } from '../../icons/v1'; import { styles, theme } from '../../style'; import { Button } from '../common/Button2'; import { Menu } from '../common/Menu'; @@ -368,6 +369,11 @@ function ActionEditor({ action, editorStyle, onChange, onDelete, onAdd }) { options, } = action; + const templated = options?.template !== undefined; + + // Even if the feature flag is disabled, we still want to be able to turn off templating + const isTemplatingEnabled = useFeatureFlag('actionTemplating') || templated; + return ( {op === 'set' ? ( @@ -388,13 +394,37 @@ function ActionEditor({ action, editorStyle, onChange, onDelete, onAdd }) { onChange('value', v)} numberFormatType="currency" /> + {/*Due to that these fields have id's as value it is not helpful to have templating here*/} + {isTemplatingEnabled && + ['payee', 'category', 'account'].indexOf(field) === -1 && ( + + )} ) : op === 'set-split-amount' ? ( <> @@ -821,18 +851,31 @@ export function EditRuleModal({ defaultRule, onSave: originalOnSave }) { id, actions: updateValue(actions, action, () => { const a = { ...action }; + if (field === 'method') { a.options = { ...a.options, method: value }; + } else if (field === 'template') { + if (value) { + a.options = { ...a.options, template: a.value }; + } else { + a.options = { ...a.options, template: undefined }; + if (a.type !== 'string') a.value = null; + } } else { a[field] = value; + if (a.options?.template !== undefined) { + a.options.template = value; + } if (field === 'field') { a.type = FIELD_TYPES.get(a.field); a.value = null; + a.options = { ...a.options, template: undefined }; return newInput(a); } else if (field === 'op') { a.value = null; a.inputKey = '' + Math.random(); + a.options = { ...a.options, template: undefined }; return newInput(a); } } diff --git a/packages/desktop-client/src/components/rules/ActionExpression.tsx b/packages/desktop-client/src/components/rules/ActionExpression.tsx index 7073d3dba18..07e8c76ee9e 100644 --- a/packages/desktop-client/src/components/rules/ActionExpression.tsx +++ b/packages/desktop-client/src/components/rules/ActionExpression.tsx @@ -71,7 +71,14 @@ function SetActionExpression({ {friendlyOp(op)}{' '} {mapField(field, options)}{' '} to - + {options?.template ? ( + <> + template + {options.template} + + ) : ( + + )} ); } diff --git a/packages/desktop-client/src/components/settings/Experimental.tsx b/packages/desktop-client/src/components/settings/Experimental.tsx index 1c73f862611..d0888e1a935 100644 --- a/packages/desktop-client/src/components/settings/Experimental.tsx +++ b/packages/desktop-client/src/components/settings/Experimental.tsx @@ -110,6 +110,12 @@ export function ExperimentalFeatures() { > Customizable reports page (dashboards) + + Rule action templating + ) : ( = { goalTemplatesEnabled: false, spendingReport: false, dashboards: false, + actionTemplating: false, }; export function useFeatureFlag(name: FeatureFlag): boolean { diff --git a/packages/loot-core/package.json b/packages/loot-core/package.json index 803ec1bc0ed..3c3ffe1cc3f 100644 --- a/packages/loot-core/package.json +++ b/packages/loot-core/package.json @@ -28,6 +28,7 @@ "csv-stringify": "^5.6.5", "date-fns": "^2.30.0", "deep-equal": "^2.2.3", + "handlebars": "^4.7.8", "lru-cache": "^5.1.1", "md5": "^2.3.0", "memoize-one": "^6.0.0", diff --git a/packages/loot-core/src/server/accounts/__snapshots__/transaction-rules.test.ts.snap b/packages/loot-core/src/server/accounts/__snapshots__/transaction-rules.test.ts.snap index 05ac017ece7..9e5e985417c 100644 --- a/packages/loot-core/src/server/accounts/__snapshots__/transaction-rules.test.ts.snap +++ b/packages/loot-core/src/server/accounts/__snapshots__/transaction-rules.test.ts.snap @@ -6,6 +6,7 @@ Array [ "actions": Array [ Action { "field": "category", + "handlebarsTemplate": undefined, "op": "set", "options": undefined, "rawValue": "food", @@ -32,6 +33,7 @@ Array [ "actions": Array [ Action { "field": "category", + "handlebarsTemplate": undefined, "op": "set", "options": undefined, "rawValue": "food", @@ -58,6 +60,7 @@ Array [ "actions": Array [ Action { "field": "category", + "handlebarsTemplate": undefined, "op": "set", "options": undefined, "rawValue": "beer", @@ -89,6 +92,7 @@ Array [ "actions": Array [ Action { "field": "category", + "handlebarsTemplate": undefined, "op": "set", "options": undefined, "rawValue": "beer", @@ -115,6 +119,7 @@ Array [ "actions": Array [ Action { "field": "category", + "handlebarsTemplate": undefined, "op": "set", "options": undefined, "rawValue": "beer", @@ -141,6 +146,7 @@ Array [ "actions": Array [ Action { "field": "category", + "handlebarsTemplate": undefined, "op": "set", "options": undefined, "rawValue": "beer", diff --git a/packages/loot-core/src/server/accounts/rules.test.ts b/packages/loot-core/src/server/accounts/rules.test.ts index 00df2b74bf3..859d7ab7e71 100644 --- a/packages/loot-core/src/server/accounts/rules.test.ts +++ b/packages/loot-core/src/server/accounts/rules.test.ts @@ -316,6 +316,99 @@ describe('Action', () => { new Action('set', 'account', '', null); }).toThrow(/Field cannot be empty/i); }); + + describe('templating', () => { + test('should use available fields', () => { + const action = new Action('set', 'notes', '', { + template: 'Hey {{notes}}! You just payed {{amount}}', + }); + const item = { notes: 'Sarah', amount: 10 }; + action.exec(item); + expect(item.notes).toBe('Hey Sarah! You just payed 10'); + }); + + describe('regex helper', () => { + function testHelper(template: string, expected: unknown) { + test(template, () => { + const action = new Action('set', 'notes', '', { template }); + const item = { notes: 'Sarah Condition' }; + action.exec(item); + expect(item.notes).toBe(expected); + }); + } + + testHelper('{{regex notes "/[aeuio]/g" "a"}}', 'Sarah Candataan'); + testHelper('{{regex notes "/[aeuio]/" ""}}', 'Srah Condition'); + // capture groups + testHelper('{{regex notes "/^.+ (.+)$/" "$1"}}', 'Condition'); + // no match + testHelper('{{regex notes "/Klaas/" "Jantje"}}', 'Sarah Condition'); + // no regex format (/.../flags) + testHelper('{{regex notes "Sarah" "Jantje"}}', 'Jantje Condition'); + }); + + describe('math helpers', () => { + function testHelper( + template: string, + expected: unknown, + field = 'amount', + ) { + test(template, () => { + const action = new Action('set', field, '', { template }); + const item = { [field]: 10 }; + action.exec(item); + expect(item[field]).toBe(expected); + }); + } + + testHelper('{{add amount 5}}', 15); + testHelper('{{add amount 5 10}}', 25); + testHelper('{{sub amount 5}}', 5); + testHelper('{{sub amount 5 10}}', -5); + testHelper('{{mul amount 5}}', 50); + testHelper('{{mul amount 5 10}}', 500); + testHelper('{{div amount 5}}', 2); + testHelper('{{div amount 5 10}}', 0.2); + testHelper('{{mod amount 3}}', 1); + testHelper('{{mod amount 6 5}}', 4); + testHelper('{{floor (div amount 3)}}', 3); + testHelper('{{ceil (div amount 3)}}', 4); + testHelper('{{round (div amount 3)}}', 3); + testHelper('{{round (div amount 4)}}', 3); + testHelper('{{abs -5}}', 5); + testHelper('{{abs 5}}', 5); + testHelper('{{min amount 5 500}}', 5); + testHelper('{{max amount 5 500}}', 500); + testHelper('{{fixed (div 10 4) 2}}', '2.50', 'notes'); + }); + + describe('date helpers', () => { + function testHelper(template: string, expected: unknown) { + test(template, () => { + const action = new Action('set', 'notes', '', { template }); + const item = { notes: '' }; + action.exec(item); + expect(item.notes).toBe(expected); + }); + } + + testHelper('{{day "2002-07-25"}}', '25'); + testHelper('{{month "2002-07-25"}}', '7'); + testHelper('{{year "2002-07-25"}}', '2002'); + testHelper('{{format "2002-07-25" "MM yyyy d"}}', '07 2002 25'); + }); + + test('{{debug}} should log the item', () => { + const action = new Action('set', 'notes', '', { + template: '{{debug notes}}', + }); + const item = { notes: 'Sarah' }; + const spy = jest.spyOn(console, 'log').mockImplementation(); + action.exec(item); + expect(spy).toHaveBeenCalledWith('Sarah'); + spy.mockRestore(); + }); + }); }); describe('Rule', () => { diff --git a/packages/loot-core/src/server/accounts/rules.ts b/packages/loot-core/src/server/accounts/rules.ts index 727e029f728..ef6c78decab 100644 --- a/packages/loot-core/src/server/accounts/rules.ts +++ b/packages/loot-core/src/server/accounts/rules.ts @@ -1,5 +1,6 @@ // @ts-strict-ignore import * as dateFns from 'date-fns'; +import * as Handlebars from 'handlebars'; import { monthFromDate, @@ -9,6 +10,8 @@ import { addDays, subDays, parseDate, + format, + currentDay, } from '../../shared/months'; import { sortNumbers, @@ -28,6 +31,62 @@ import { RuleConditionEntity } from '../../types/models'; import { RuleError } from '../errors'; import { Schedule as RSchedule } from '../util/rschedule'; +function registerHandlebarsHelpers() { + const regexTest = /^\/(.*)\/([gimuy]*)$/; + + function mathHelper(fn: (a: number, b: number) => number) { + return (a: unknown, ...b: unknown[]) => { + // Last argument is the Handlebars options object + b.splice(-1, 1); + return b.map(Number).reduce(fn, Number(a)); + }; + } + + const helpers = { + regex: (value: unknown, regex: unknown, replace: unknown) => { + if (typeof regex !== 'string' || typeof replace !== 'string') { + return ''; + } + + let regexp: RegExp; + const match = regexTest.exec(regex); + // Regex is in format /regex/flags + if (match) { + regexp = new RegExp(match[1], match[2]); + } else { + regexp = new RegExp(regex); + } + + return String(value).replace(regexp, replace); + }, + add: mathHelper((a, b) => a + b), + sub: mathHelper((a, b) => a - b), + div: mathHelper((a, b) => a / b), + mul: mathHelper((a, b) => a * b), + mod: mathHelper((a, b) => a % b), + floor: (a: unknown) => Math.floor(Number(a)), + ceil: (a: unknown) => Math.ceil(Number(a)), + round: (a: unknown) => Math.round(Number(a)), + abs: (a: unknown) => Math.abs(Number(a)), + min: mathHelper((a, b) => Math.min(a, b)), + max: mathHelper((a, b) => Math.max(a, b)), + fixed: (a: unknown, digits: unknown) => Number(a).toFixed(Number(digits)), + day: (date: string) => format(date, 'd'), + month: (date: string) => format(date, 'M'), + year: (date: string) => format(date, 'yyyy'), + format: (date: string, f: string) => format(date, f), + debug: (value: unknown) => { + console.log(value); + }, + }; + + for (const [name, fn] of Object.entries(helpers)) { + Handlebars.registerHelper(name, fn); + } +} + +registerHandlebarsHelpers(); + function assert(test, type, msg) { if (!test) { throw new RuleError(type, msg); @@ -491,6 +550,8 @@ export class Action { type; value; + private handlebarsTemplate?: Handlebars.TemplateDelegate; + constructor(op: ActionOperator, field, value, options) { assert( ACTION_OPS.includes(op), @@ -503,6 +564,15 @@ export class Action { assert(typeName, 'internal', `Invalid field for action: ${field}`); this.field = field; this.type = typeName; + if (options?.template) { + this.handlebarsTemplate = Handlebars.compile(options.template); + try { + this.handlebarsTemplate({}); + } catch (e) { + console.debug(e); + assert(false, 'invalid-template', `Invalid Handlebars template`); + } + } } else if (op === 'set-split-amount') { this.field = null; this.type = 'number'; @@ -527,7 +597,27 @@ export class Action { exec(object) { switch (this.op) { case 'set': - object[this.field] = this.value; + if (this.handlebarsTemplate) { + object[this.field] = this.handlebarsTemplate({ + ...object, + today: currentDay(), + }); + + // Handlebars always returns a string, so we need to convert + switch (this.type) { + case 'number': + object[this.field] = parseFloat(object[this.field]); + break; + case 'date': + object[this.field] = parseDate(object[this.field]); + break; + case 'boolean': + object[this.field] = object[this.field] === 'true'; + break; + } + } else { + object[this.field] = this.value; + } break; case 'set-split-amount': switch (this.options.method) { diff --git a/packages/loot-core/src/shared/rules.ts b/packages/loot-core/src/shared/rules.ts index aac09411aa4..fe4839b2229 100644 --- a/packages/loot-core/src/shared/rules.ts +++ b/packages/loot-core/src/shared/rules.ts @@ -219,6 +219,8 @@ export function getFieldError(type) { return 'Value must be a number'; case 'invalid-field': return 'Please choose a valid field for this type of rule'; + case 'invalid-template': + return 'Invalid handlebars template'; default: return 'Internal error, sorry! Please get in touch https://actualbudget.org/contact/ for support'; } diff --git a/packages/loot-core/src/types/models/rule.d.ts b/packages/loot-core/src/types/models/rule.d.ts index 3fcde847ff5..8e7cf96b014 100644 --- a/packages/loot-core/src/types/models/rule.d.ts +++ b/packages/loot-core/src/types/models/rule.d.ts @@ -138,6 +138,7 @@ export interface SetRuleActionEntity { op: 'set'; value: unknown; options?: { + template?: string; splitIndex?: number; }; type?: string; diff --git a/packages/loot-core/src/types/prefs.d.ts b/packages/loot-core/src/types/prefs.d.ts index f1066746732..036d609f2d4 100644 --- a/packages/loot-core/src/types/prefs.d.ts +++ b/packages/loot-core/src/types/prefs.d.ts @@ -2,7 +2,8 @@ export type FeatureFlag = | 'dashboards' | 'reportBudget' | 'goalTemplatesEnabled' - | 'spendingReport'; + | 'spendingReport' + | 'actionTemplating'; /** * Cross-device preferences. These sync across devices when they are changed. diff --git a/upcoming-release-notes/3305.md b/upcoming-release-notes/3305.md new file mode 100644 index 00000000000..34a65eac692 --- /dev/null +++ b/upcoming-release-notes/3305.md @@ -0,0 +1,6 @@ +--- +category: Enhancements +authors: [UnderKoen] +--- + +Add rule action templating for set actions using handlebars syntax. diff --git a/yarn.lock b/yarn.lock index 74342bcbf92..29ce52e201a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10719,6 +10719,24 @@ __metadata: languageName: node linkType: hard +"handlebars@npm:^4.7.8": + version: 4.7.8 + resolution: "handlebars@npm:4.7.8" + dependencies: + minimist: "npm:^1.2.5" + neo-async: "npm:^2.6.2" + source-map: "npm:^0.6.1" + uglify-js: "npm:^3.1.4" + wordwrap: "npm:^1.0.0" + dependenciesMeta: + uglify-js: + optional: true + bin: + handlebars: bin/handlebars + checksum: 10/bd528f4dd150adf67f3f857118ef0fa43ff79a153b1d943fa0a770f2599e38b25a7a0dbac1a3611a4ec86970fd2325a81310fb788b5c892308c9f8743bd02e11 + languageName: node + linkType: hard + "has-bigints@npm:^1.0.1, has-bigints@npm:^1.0.2": version: 1.0.2 resolution: "has-bigints@npm:1.0.2" @@ -13151,6 +13169,7 @@ __metadata: deep-equal: "npm:^2.2.3" fake-indexeddb: "npm:^3.1.8" fast-check: "npm:3.15.0" + handlebars: "npm:^4.7.8" i18next: "npm:^23.11.5" jest: "npm:^27.5.1" jsverify: "npm:^0.8.4" @@ -14081,7 +14100,7 @@ __metadata: languageName: node linkType: hard -"minimist@npm:^1.2.0, minimist@npm:^1.2.3, minimist@npm:^1.2.6": +"minimist@npm:^1.2.0, minimist@npm:^1.2.3, minimist@npm:^1.2.5, minimist@npm:^1.2.6": version: 1.2.8 resolution: "minimist@npm:1.2.8" checksum: 10/908491b6cc15a6c440ba5b22780a0ba89b9810e1aea684e253e43c4e3b8d56ec1dcdd7ea96dde119c29df59c936cde16062159eae4225c691e19c70b432b6e6f @@ -18600,6 +18619,15 @@ __metadata: languageName: node linkType: hard +"uglify-js@npm:^3.1.4": + version: 3.19.2 + resolution: "uglify-js@npm:3.19.2" + bin: + uglifyjs: bin/uglifyjs + checksum: 10/8b0af1fa5260e7f8bc3e9a1e08ae05023b7c96eeb8965e27f29724597389d4e703d4aa6f66e6cd87a14a84e431df73a358ee58c0afce6b615b40cc95fcbf4ec6 + languageName: node + linkType: hard + "unbox-primitive@npm:^1.0.2": version: 1.0.2 resolution: "unbox-primitive@npm:1.0.2" @@ -19653,6 +19681,13 @@ __metadata: languageName: node linkType: hard +"wordwrap@npm:^1.0.0": + version: 1.0.0 + resolution: "wordwrap@npm:1.0.0" + checksum: 10/497d40beb2bdb08e6d38754faa17ce20b0bf1306327f80cb777927edb23f461ee1f6bc659b3c3c93f26b08e1cf4b46acc5bae8fda1f0be3b5ab9a1a0211034cd + languageName: node + linkType: hard + "workbox-background-sync@npm:7.1.0": version: 7.1.0 resolution: "workbox-background-sync@npm:7.1.0"