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