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

Merged
merged 23 commits into from
Oct 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
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,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);
});
}
UnderKoen marked this conversation as resolved.
Show resolved Hide resolved

testHelper('{{regex notes "/[aeuio]/g" "a"}}', 'Sarah Candataan');
testHelper('{{regex notes "/[aeuio]/" ""}}', 'Srah Condition');
UnderKoen marked this conversation as resolved.
Show resolved Hide resolved
// 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');
});
UnderKoen marked this conversation as resolved.
Show resolved Hide resolved

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

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