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
This commit is contained in:
Koen van Staveren
2024-10-08 18:43:04 +02:00
committed by GitHub
parent 464d9878c6
commit ce4b80f499
13 changed files with 299 additions and 7 deletions

View File

@@ -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 (
<Editor style={editorStyle} error={error}>
{op === 'set' ? (
@@ -388,13 +394,37 @@ function ActionEditor({ action, editorStyle, onChange, onDelete, onAdd }) {
<GenericInput
key={inputKey}
field={field}
type={type}
type={templated ? 'string' : type}
op={op}
value={value}
value={options?.template ?? value}
onChange={v => onChange('value', v)}
numberFormatType="currency"
/>
</View>
{/*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 && (
<Button
variant="bare"
style={{
padding: 5,
}}
aria-label={
templated ? 'Disable templating' : 'Enable templating'
}
onPress={() => onChange('template', !templated)}
>
{templated ? (
<SvgCode
style={{ width: 12, height: 12, color: 'inherit' }}
/>
) : (
<SvgAlignLeft
style={{ width: 12, height: 12, color: 'inherit' }}
/>
)}
</Button>
)}
</>
) : 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);
}
}

View File

@@ -71,7 +71,14 @@ function SetActionExpression({
<Text>{friendlyOp(op)}</Text>{' '}
<Text style={valueStyle}>{mapField(field, options)}</Text>{' '}
<Text>to </Text>
<Value style={valueStyle} value={value} field={field} />
{options?.template ? (
<>
<Text>template </Text>
<Text style={valueStyle}>{options.template}</Text>
</>
) : (
<Value style={valueStyle} value={value} field={field} />
)}
</>
);
}

View File

@@ -110,6 +110,12 @@ export function ExperimentalFeatures() {
>
<Trans>Customizable reports page (dashboards)</Trans>
</FeatureToggle>
<FeatureToggle
flag="actionTemplating"
feedbackLink="https://github.com/actualbudget/actual/issues/3606"
>
<Trans>Rule action templating</Trans>
</FeatureToggle>
</View>
) : (
<Link

View File

@@ -7,6 +7,7 @@ const DEFAULT_FEATURE_FLAG_STATE: Record<FeatureFlag, boolean> = {
goalTemplatesEnabled: false,
spendingReport: false,
dashboards: false,
actionTemplating: false,
};
export function useFeatureFlag(name: FeatureFlag): boolean {

View File

@@ -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",

View File

@@ -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",

View File

@@ -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', () => {

View File

@@ -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) {

View File

@@ -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';
}

View File

@@ -138,6 +138,7 @@ export interface SetRuleActionEntity {
op: 'set';
value: unknown;
options?: {
template?: string;
splitIndex?: number;
};
type?: string;

View File

@@ -2,7 +2,8 @@ export type FeatureFlag =
| 'dashboards'
| 'reportBudget'
| 'goalTemplatesEnabled'
| 'spendingReport';
| 'spendingReport'
| 'actionTemplating';
/**
* Cross-device preferences. These sync across devices when they are changed.

View File

@@ -0,0 +1,6 @@
---
category: Enhancements
authors: [UnderKoen]
---
Add rule action templating for set actions using handlebars syntax.

View File

@@ -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"