Skip to content

Commit

Permalink
feat(rules): templating actions (#3305)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
UnderKoen authored Oct 8, 2024
1 parent 464d987 commit ce4b80f
Show file tree
Hide file tree
Showing 13 changed files with 299 additions and 7 deletions.
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,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
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
93 changes: 93 additions & 0 deletions packages/loot-core/src/server/accounts/rules.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Loading

0 comments on commit ce4b80f

Please sign in to comment.