Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(rules): templating actions #3305

Open
wants to merge 19 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 18 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 46 additions & 3 deletions packages/desktop-client/src/components/modals/EditRuleModal.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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' ? (
Expand All @@ -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' ? (
<>
Expand Down Expand Up @@ -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);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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} />
)}
</>
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,9 @@ export function ExperimentalFeatures() {
>
<Trans>Customizable reports page (dashboards)</Trans>
</FeatureToggle>
<FeatureToggle flag="actionTemplating">
<Trans>Action templating</Trans>
</FeatureToggle>
</View>
) : (
<Link
Expand Down
1 change: 1 addition & 0 deletions packages/desktop-client/src/hooks/useFeatureFlag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions packages/loot-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ Array [
"actions": Array [
Action {
"field": "category",
"handlebarsTemplate": undefined,
"op": "set",
"options": undefined,
"rawValue": "food",
Expand All @@ -32,6 +33,7 @@ Array [
"actions": Array [
Action {
"field": "category",
"handlebarsTemplate": undefined,
"op": "set",
"options": undefined,
"rawValue": "food",
Expand All @@ -58,6 +60,7 @@ Array [
"actions": Array [
Action {
"field": "category",
"handlebarsTemplate": undefined,
"op": "set",
"options": undefined,
"rawValue": "beer",
Expand Down Expand Up @@ -89,6 +92,7 @@ Array [
"actions": Array [
Action {
"field": "category",
"handlebarsTemplate": undefined,
"op": "set",
"options": undefined,
"rawValue": "beer",
Expand All @@ -115,6 +119,7 @@ Array [
"actions": Array [
Action {
"field": "category",
"handlebarsTemplate": undefined,
"op": "set",
"options": undefined,
"rawValue": "beer",
Expand All @@ -141,6 +146,7 @@ Array [
"actions": Array [
Action {
"field": "category",
"handlebarsTemplate": undefined,
"op": "set",
"options": undefined,
"rawValue": "beer",
Expand Down
86 changes: 85 additions & 1 deletion packages/loot-core/src/server/accounts/rules.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// @ts-strict-ignore
import * as dateFns from 'date-fns';
import * as Handlebars from 'handlebars';

import {
monthFromDate,
Expand All @@ -9,6 +10,8 @@ import {
addDays,
subDays,
parseDate,
format,
currentDay,
UnderKoen marked this conversation as resolved.
Show resolved Hide resolved
} from '../../shared/months';
import {
sortNumbers,
Expand All @@ -28,6 +31,57 @@ import { RuleConditionEntity } from '../../types/models';
import { RuleError } from '../errors';
import { Schedule as RSchedule } from '../util/rschedule';

void (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));
};
}
UnderKoen marked this conversation as resolved.
Show resolved Hide resolved

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);
},
UnderKoen marked this conversation as resolved.
Show resolved Hide resolved
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(Math.min),
max: mathHelper(Math.max),
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),
UnderKoen marked this conversation as resolved.
Show resolved Hide resolved
};

for (const [name, fn] of Object.entries(helpers)) {
Handlebars.registerHelper(name, fn);
}
})();
UnderKoen marked this conversation as resolved.
Show resolved Hide resolved

UnderKoen marked this conversation as resolved.
Show resolved Hide resolved
function assert(test, type, msg) {
if (!test) {
throw new RuleError(type, msg);
Expand Down Expand Up @@ -491,6 +545,8 @@ export class Action {
type;
value;

private handlebarsTemplate?: Handlebars.TemplateDelegate;

UnderKoen marked this conversation as resolved.
Show resolved Hide resolved
constructor(op: ActionOperator, field, value, options) {
assert(
ACTION_OPS.includes(op),
Expand All @@ -503,6 +559,14 @@ 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) {
assert(false, 'invalid-template', `Invalid Handlebars template`);
}
}
UnderKoen marked this conversation as resolved.
Show resolved Hide resolved
} else if (op === 'set-split-amount') {
this.field = null;
this.type = 'number';
Expand All @@ -527,7 +591,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(),
});
UnderKoen marked this conversation as resolved.
Show resolved Hide resolved

// 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;
}
UnderKoen marked this conversation as resolved.
Show resolved Hide resolved
} else {
object[this.field] = this.value;
}
Comment on lines +597 to +617
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Add error handling for type conversions after template execution.

The current implementation doesn't handle potential errors in type conversion, such as NaN for numbers or invalid dates. Consider adding checks to ensure the converted values are valid.

Apply this diff to include conversion checks:

 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]);
+      const num = parseFloat(object[this.field]);
+      if (isNaN(num)) {
+        throw new Error(`Invalid number result from template for field ${this.field}`);
+      }
+      object[this.field] = num;
       break;
     case 'date':
-      object[this.field] = parseDate(object[this.field]);
+      const date = parseDate(object[this.field]);
+      if (isNaN(date.getTime())) {
+        throw new Error(`Invalid date result from template for field ${this.field}`);
+      }
+      object[this.field] = date;
       break;
     case 'boolean':
       object[this.field] = object[this.field] === 'true';
       break;
   }
 } else {
   object[this.field] = this.value;
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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;
}
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':
const num = parseFloat(object[this.field]);
if (isNaN(num)) {
throw new Error(`Invalid number result from template for field ${this.field}`);
}
object[this.field] = num;
break;
case 'date':
const date = parseDate(object[this.field]);
if (isNaN(date.getTime())) {
throw new Error(`Invalid date result from template for field ${this.field}`);
}
object[this.field] = date;
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) {
Expand Down
2 changes: 2 additions & 0 deletions packages/loot-core/src/shared/rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}
Expand Down
1 change: 1 addition & 0 deletions packages/loot-core/src/types/models/rule.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ export interface SetRuleActionEntity {
op: 'set';
value: unknown;
options?: {
template?: string;
splitIndex?: number;
};
type?: string;
Expand Down
3 changes: 2 additions & 1 deletion packages/loot-core/src/types/prefs.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ export type FeatureFlag =
| 'dashboards'
| 'reportBudget'
| 'goalTemplatesEnabled'
| 'spendingReport';
| 'spendingReport'
| 'actionTemplating';

/**
* Cross-device preferences. These sync across devices when they are changed.
Expand Down
6 changes: 6 additions & 0 deletions upcoming-release-notes/3305.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
category: Enhancements
authors: [UnderKoen]
---

Add rule action templating for set actions using handlebars syntax.
Loading
Loading