mirror of
https://github.com/actualbudget/actual.git
synced 2026-03-11 20:44:32 -05:00
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:
committed by
GitHub
parent
464d9878c6
commit
ce4b80f499
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -138,6 +138,7 @@ export interface SetRuleActionEntity {
|
||||
op: 'set';
|
||||
value: unknown;
|
||||
options?: {
|
||||
template?: string;
|
||||
splitIndex?: number;
|
||||
};
|
||||
type?: string;
|
||||
|
||||
3
packages/loot-core/src/types/prefs.d.ts
vendored
3
packages/loot-core/src/types/prefs.d.ts
vendored
@@ -2,7 +2,8 @@ export type FeatureFlag =
|
||||
| 'dashboards'
|
||||
| 'reportBudget'
|
||||
| 'goalTemplatesEnabled'
|
||||
| 'spendingReport';
|
||||
| 'spendingReport'
|
||||
| 'actionTemplating';
|
||||
|
||||
/**
|
||||
* Cross-device preferences. These sync across devices when they are changed.
|
||||
|
||||
6
upcoming-release-notes/3305.md
Normal file
6
upcoming-release-notes/3305.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Enhancements
|
||||
authors: [UnderKoen]
|
||||
---
|
||||
|
||||
Add rule action templating for set actions using handlebars syntax.
|
||||
37
yarn.lock
37
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"
|
||||
|
||||
Reference in New Issue
Block a user