From af73dcd7229082e2de7ab3c8fbd4cdaaac81c4c0 Mon Sep 17 00:00:00 2001
From: Robert Dyer <rdyer@unl.edu>
Date: Thu, 15 Aug 2024 08:36:44 -0500
Subject: [PATCH] Add rule actions to prepend/append to transaction notes.
 (#3215)

* Add rule action to append to transaction notes.

* add release note

* support prepending

* fix linter

* update release note

* fix typecheck error

* update VRT test code

* revising VRT code

* select by row

* fix missing delete button

* fix VRT tests

* fix linter

* empty commit for CI

* avoid 'undefined' appearing in notes

* fix linter
---
 .../e2e/page-models/rules-page.js             | 41 ++++++------
 packages/desktop-client/e2e/rules.test.js     | 49 +++++++-------
 .../src/components/ManageRules.tsx            |  5 ++
 .../src/components/modals/EditRule.jsx        | 67 ++++++++++++++-----
 .../src/components/rules/ActionExpression.tsx | 27 ++++++++
 .../loot-core/src/server/accounts/rules.ts    | 21 +++++-
 .../src/server/accounts/transaction-rules.ts  |  5 ++
 packages/loot-core/src/server/rules/app.ts    | 16 +++--
 packages/loot-core/src/shared/rules.ts        |  4 ++
 packages/loot-core/src/types/models/rule.d.ts | 14 +++-
 upcoming-release-notes/3215.md                |  6 ++
 11 files changed, 185 insertions(+), 70 deletions(-)
 create mode 100644 upcoming-release-notes/3215.md

diff --git a/packages/desktop-client/e2e/page-models/rules-page.js b/packages/desktop-client/e2e/page-models/rules-page.js
index 051cbe2ca..28f0c06fb 100644
--- a/packages/desktop-client/e2e/page-models/rules-page.js
+++ b/packages/desktop-client/e2e/page-models/rules-page.js
@@ -52,6 +52,7 @@ export class RulesPage {
       await this._fillEditorFields(
         data.conditions,
         this.page.getByTestId('condition-list'),
+        true,
       );
     }
 
@@ -63,28 +64,19 @@ export class RulesPage {
     }
 
     if (data.splits) {
-      if (data.splits.beforeSplitActions) {
+      let idx = data.actions?.length ?? 0;
+      for (const splitActions of data.splits) {
+        await this.page.getByTestId('add-split-transactions').click();
         await this._fillEditorFields(
-          data.splits.beforeSplitActions,
-          this.page.getByTestId('action-list'),
+          splitActions,
+          this.page.getByTestId('action-list').nth(idx),
         );
-      }
-
-      if (data.splits.splitActions) {
-        let idx = data.splits?.beforeSplitActions.length ?? 0;
-        for (const splitActions of data.splits.splitActions) {
-          await this.page.getByTestId('add-split-transactions').click();
-          await this._fillEditorFields(
-            splitActions,
-            this.page.getByTestId('action-list').nth(idx),
-          );
-          idx++;
-        }
+        idx++;
       }
     }
   }
 
-  async _fillEditorFields(data, rootElement) {
+  async _fillEditorFields(data, rootElement, fieldFirst = false) {
     for (const idx in data) {
       const { field, op, value } = data[idx];
 
@@ -94,15 +86,24 @@ export class RulesPage {
         await rootElement.getByRole('button', { name: 'Add entry' }).click();
       }
 
+      if (op && !fieldFirst) {
+        await row.getByTestId('op-select').getByRole('button').first().click();
+        await this.page.getByRole('button', { name: op, exact: true }).click();
+      }
+
       if (field) {
-        await row.getByRole('button').first().click();
+        await row
+          .getByTestId('field-select')
+          .getByRole('button')
+          .first()
+          .click();
         await this.page
-          .getByRole('button', { exact: true, name: field })
+          .getByRole('button', { name: field, exact: true })
           .click();
       }
 
-      if (op) {
-        await row.getByRole('button', { name: 'is' }).click();
+      if (op && fieldFirst) {
+        await row.getByTestId('op-select').getByRole('button').first().click();
         await this.page.getByRole('button', { name: op, exact: true }).click();
       }
 
diff --git a/packages/desktop-client/e2e/rules.test.js b/packages/desktop-client/e2e/rules.test.js
index 346256715..74db81652 100644
--- a/packages/desktop-client/e2e/rules.test.js
+++ b/packages/desktop-client/e2e/rules.test.js
@@ -79,35 +79,34 @@ test.describe('Rules', () => {
           value: 'Ikea',
         },
       ],
-      splits: {
-        beforeSplitActions: [
+      actions: [
+        {
+          op: 'set',
+          field: 'notes',
+          value: 'food / entertainment',
+        },
+      ],
+      splits: [
+        [
+          {
+            field: 'a fixed percent of the remainder',
+            value: '90',
+          },
           {
-            field: 'notes',
-            value: 'food / entertainment',
+            field: 'category',
+            value: 'Entertainment',
           },
         ],
-        splitActions: [
-          [
-            {
-              field: 'a fixed percent of the remainder',
-              value: '90',
-            },
-            {
-              field: 'category',
-              value: 'Entertainment',
-            },
-          ],
-          [
-            {
-              field: 'an equal portion of the remainder',
-            },
-            {
-              field: 'category',
-              value: 'Food',
-            },
-          ],
+        [
+          {
+            field: 'an equal portion of the remainder',
+          },
+          {
+            field: 'category',
+            value: 'Food',
+          },
         ],
-      },
+      ],
     });
 
     const accountPage = await navigation.goToAccountPage(
diff --git a/packages/desktop-client/src/components/ManageRules.tsx b/packages/desktop-client/src/components/ManageRules.tsx
index d627b98e3..a5c68ac6f 100644
--- a/packages/desktop-client/src/components/ManageRules.tsx
+++ b/packages/desktop-client/src/components/ManageRules.tsx
@@ -79,6 +79,11 @@ function ruleToString(rule, data) {
           data.payees.find(p => p.id === schedule._payee),
         ),
       ];
+    } else if (action.op === 'prepend-notes' || action.op === 'append-notes') {
+      return [
+        friendlyOp(action.op),
+        '“' + mapValue(action.field, action.value, data) + '”',
+      ];
     } else {
       return [];
     }
diff --git a/packages/desktop-client/src/components/modals/EditRule.jsx b/packages/desktop-client/src/components/modals/EditRule.jsx
index ad092f26d..b896846fc 100644
--- a/packages/desktop-client/src/components/modals/EditRule.jsx
+++ b/packages/desktop-client/src/components/modals/EditRule.jsx
@@ -89,7 +89,7 @@ function getTransactionFields(conditions, actions) {
 
 export function FieldSelect({ fields, style, value, onChange }) {
   return (
-    <View style={style}>
+    <View style={style} data-testid="field-select">
       <Select
         bare
         options={fields}
@@ -129,19 +129,24 @@ export function OpSelect({
   }, [ops, type]);
 
   return (
-    <Select
-      bare
-      options={opOptions}
-      value={value}
-      onChange={value => onChange('op', value)}
-      buttonStyle={style}
-    />
+    <View data-testid="op-select">
+      <Select
+        bare
+        options={opOptions}
+        value={value}
+        onChange={value => onChange('op', value)}
+        buttonStyle={style}
+      />
+    </View>
   );
 }
 
 function SplitAmountMethodSelect({ options, style, value, onChange }) {
   return (
-    <View style={{ color: theme.pageTextPositive, ...style }}>
+    <View
+      style={{ color: theme.pageTextPositive, ...style }}
+      data-testid="field-select"
+    >
       <Select
         bare
         options={options}
@@ -357,13 +362,13 @@ function ActionEditor({ action, editorStyle, onChange, onDelete, onAdd }) {
 
   return (
     <Editor style={editorStyle} error={error}>
-      {/*<OpSelect ops={ops} value={op} onChange={onChange} />*/}
-
       {op === 'set' ? (
         <>
-          <View style={{ padding: '5px 10px', lineHeight: '1em' }}>
-            {friendlyOp(op)}
-          </View>
+          <OpSelect
+            ops={['set', 'prepend-notes', 'append-notes']}
+            value={op}
+            onChange={onChange}
+          />
 
           <FieldSelect
             fields={options?.splitIndex ? splitActionFields : actionFields}
@@ -422,10 +427,35 @@ function ActionEditor({ action, editorStyle, onChange, onDelete, onAdd }) {
           </View>
           <ScheduleDescription id={value || null} />
         </>
+      ) : op === 'prepend-notes' || op === 'append-notes' ? (
+        <>
+          <OpSelect
+            ops={['set', 'prepend-notes', 'append-notes']}
+            value={op}
+            onChange={onChange}
+          />
+
+          <View style={{ flex: 1 }}>
+            <GenericInput
+              key={inputKey}
+              field={field}
+              type="string"
+              op={op}
+              value={value}
+              onChange={v => onChange('value', v)}
+            />
+          </View>
+        </>
       ) : null}
 
       <Stack direction="row">
-        <EditorButtons onAdd={onAdd} onDelete={op === 'set' && onDelete} />
+        <EditorButtons
+          onAdd={onAdd}
+          onDelete={
+            (op === 'set' || op === 'prepend-notes' || op === 'append-notes') &&
+            onDelete
+          }
+        />
       </Stack>
     </Editor>
   );
@@ -1067,7 +1097,12 @@ export function EditRule({ defaultRule, onSave: originalOnSave }) {
                           {actions.map((action, actionIndex) => (
                             <View key={actionIndex}>
                               <ActionEditor
-                                ops={['set', 'link-schedule']}
+                                ops={[
+                                  'set',
+                                  'link-schedule',
+                                  'prepend-notes',
+                                  'append-notes',
+                                ]}
                                 action={action}
                                 editorStyle={editorStyle}
                                 onChange={(name, value) => {
diff --git a/packages/desktop-client/src/components/rules/ActionExpression.tsx b/packages/desktop-client/src/components/rules/ActionExpression.tsx
index fa55e6d85..7073d3dba 100644
--- a/packages/desktop-client/src/components/rules/ActionExpression.tsx
+++ b/packages/desktop-client/src/components/rules/ActionExpression.tsx
@@ -10,6 +10,8 @@ import {
   type LinkScheduleRuleActionEntity,
   type RuleActionEntity,
   type SetRuleActionEntity,
+  type AppendNoteRuleActionEntity,
+  type PrependNoteRuleActionEntity,
 } from 'loot-core/src/types/models';
 
 import { type CSSProperties, theme } from '../../style';
@@ -49,6 +51,10 @@ export function ActionExpression({ style, ...props }: ActionExpressionProps) {
         <SetSplitAmountActionExpression {...props} />
       ) : props.op === 'link-schedule' ? (
         <LinkScheduleActionExpression {...props} />
+      ) : props.op === 'prepend-notes' ? (
+        <PrependNoteActionExpression {...props} />
+      ) : props.op === 'append-notes' ? (
+        <AppendNoteActionExpression {...props} />
       ) : null}
     </View>
   );
@@ -103,3 +109,24 @@ function LinkScheduleActionExpression({
     </>
   );
 }
+
+function PrependNoteActionExpression({
+  op,
+  value,
+}: PrependNoteRuleActionEntity) {
+  return (
+    <>
+      <Text>{friendlyOp(op)}</Text>{' '}
+      <Value style={valueStyle} value={value} field="notes" />
+    </>
+  );
+}
+
+function AppendNoteActionExpression({ op, value }: AppendNoteRuleActionEntity) {
+  return (
+    <>
+      <Text>{friendlyOp(op)}</Text>{' '}
+      <Value style={valueStyle} value={value} field="notes" />
+    </>
+  );
+}
diff --git a/packages/loot-core/src/server/accounts/rules.ts b/packages/loot-core/src/server/accounts/rules.ts
index a72c8b628..32a47b073 100644
--- a/packages/loot-core/src/server/accounts/rules.ts
+++ b/packages/loot-core/src/server/accounts/rules.ts
@@ -440,7 +440,13 @@ export class Condition {
   }
 }
 
-const ACTION_OPS = ['set', 'set-split-amount', 'link-schedule'] as const;
+const ACTION_OPS = [
+  'set',
+  'set-split-amount',
+  'link-schedule',
+  'prepend-notes',
+  'append-notes',
+] as const;
 type ActionOperator = (typeof ACTION_OPS)[number];
 
 export class Action {
@@ -469,6 +475,9 @@ export class Action {
     } else if (op === 'link-schedule') {
       this.field = null;
       this.type = 'id';
+    } else if (op === 'prepend-notes' || op === 'append-notes') {
+      this.field = 'notes';
+      this.type = 'id';
     }
 
     if (field === 'account') {
@@ -497,6 +506,16 @@ export class Action {
       case 'link-schedule':
         object.schedule = this.value;
         break;
+      case 'prepend-notes':
+        object[this.field] = object[this.field]
+          ? this.value + object[this.field]
+          : this.value;
+        break;
+      case 'append-notes':
+        object[this.field] = object[this.field]
+          ? object[this.field] + this.value
+          : this.value;
+        break;
       default:
     }
   }
diff --git a/packages/loot-core/src/server/accounts/transaction-rules.ts b/packages/loot-core/src/server/accounts/transaction-rules.ts
index 44a8e223c..66024acf8 100644
--- a/packages/loot-core/src/server/accounts/transaction-rules.ts
+++ b/packages/loot-core/src/server/accounts/transaction-rules.ts
@@ -520,6 +520,11 @@ export async function applyActions(
           );
         } else if (action.op === 'link-schedule') {
           return new Action(action.op, null, action.value, null, FIELD_TYPES);
+        } else if (
+          action.op === 'prepend-notes' ||
+          action.op === 'append-notes'
+        ) {
+          return new Action(action.op, null, action.value, null, FIELD_TYPES);
         }
 
         return new Action(
diff --git a/packages/loot-core/src/server/rules/app.ts b/packages/loot-core/src/server/rules/app.ts
index df9e272bd..95635fba7 100644
--- a/packages/loot-core/src/server/rules/app.ts
+++ b/packages/loot-core/src/server/rules/app.ts
@@ -56,13 +56,15 @@ function validateRule(rule: Partial<RuleEntity>) {
         )
       : action.op === 'link-schedule'
         ? new Action(action.op, null, action.value, null, ruleFieldTypes)
-        : new Action(
-            action.op,
-            action.field,
-            action.value,
-            action.options,
-            ruleFieldTypes,
-          ),
+        : action.op === 'prepend-notes' || action.op === 'append-notes'
+          ? new Action(action.op, null, action.value, null, ruleFieldTypes)
+          : new Action(
+              action.op,
+              action.field,
+              action.value,
+              action.options,
+              ruleFieldTypes,
+            ),
   );
 
   if (conditionErrors || actionErrors) {
diff --git a/packages/loot-core/src/shared/rules.ts b/packages/loot-core/src/shared/rules.ts
index 4913a58cd..b920d1b0c 100644
--- a/packages/loot-core/src/shared/rules.ts
+++ b/packages/loot-core/src/shared/rules.ts
@@ -141,6 +141,10 @@ export function friendlyOp(op, type?) {
       return 'allocate';
     case 'link-schedule':
       return 'link schedule';
+    case 'prepend-notes':
+      return 'prepend to notes';
+    case 'append-notes':
+      return 'append to notes';
     case 'and':
       return 'and';
     case 'or':
diff --git a/packages/loot-core/src/types/models/rule.d.ts b/packages/loot-core/src/types/models/rule.d.ts
index a2e0fb3f1..3c61ebf70 100644
--- a/packages/loot-core/src/types/models/rule.d.ts
+++ b/packages/loot-core/src/types/models/rule.d.ts
@@ -124,7 +124,9 @@ export type RuleConditionEntity =
 export type RuleActionEntity =
   | SetRuleActionEntity
   | SetSplitAmountRuleActionEntity
-  | LinkScheduleRuleActionEntity;
+  | LinkScheduleRuleActionEntity
+  | PrependNoteRuleActionEntity
+  | AppendNoteRuleActionEntity;
 
 export interface SetRuleActionEntity {
   field: string;
@@ -149,3 +151,13 @@ export interface LinkScheduleRuleActionEntity {
   op: 'link-schedule';
   value: ScheduleEntity;
 }
+
+export interface PrependNoteRuleActionEntity {
+  op: 'prepend-notes';
+  value: string;
+}
+
+export interface AppendNoteRuleActionEntity {
+  op: 'append-notes';
+  value: string;
+}
diff --git a/upcoming-release-notes/3215.md b/upcoming-release-notes/3215.md
new file mode 100644
index 000000000..85523f4c9
--- /dev/null
+++ b/upcoming-release-notes/3215.md
@@ -0,0 +1,6 @@
+---
+category: Enhancements
+authors: [psybers]
+---
+
+Add rule actions to prepend/append to transaction notes.
-- 
GitLab