diff --git a/packages/desktop-client/e2e/transactions.test.js-snapshots/Transactions-filters-transactions-by-category-1-chromium-linux.png b/packages/desktop-client/e2e/transactions.test.js-snapshots/Transactions-filters-transactions-by-category-1-chromium-linux.png index 4a682c1f0ed07e7f0cd771fe8e89e1439e202cdb..a099f6a117abe1106202058efae675b955f3c28b 100644 Binary files a/packages/desktop-client/e2e/transactions.test.js-snapshots/Transactions-filters-transactions-by-category-1-chromium-linux.png and b/packages/desktop-client/e2e/transactions.test.js-snapshots/Transactions-filters-transactions-by-category-1-chromium-linux.png differ diff --git a/packages/desktop-client/e2e/transactions.test.js-snapshots/Transactions-filters-transactions-by-category-2-chromium-linux.png b/packages/desktop-client/e2e/transactions.test.js-snapshots/Transactions-filters-transactions-by-category-2-chromium-linux.png index f98e9d0f63e8864efa00d4a9be038a01762b653e..1b27a5bcb6320ca94a324961eb2c7391da039ef6 100644 Binary files a/packages/desktop-client/e2e/transactions.test.js-snapshots/Transactions-filters-transactions-by-category-2-chromium-linux.png and b/packages/desktop-client/e2e/transactions.test.js-snapshots/Transactions-filters-transactions-by-category-2-chromium-linux.png differ diff --git a/packages/desktop-client/e2e/transactions.test.js-snapshots/Transactions-filters-transactions-by-category-3-chromium-linux.png b/packages/desktop-client/e2e/transactions.test.js-snapshots/Transactions-filters-transactions-by-category-3-chromium-linux.png index 6e51c3f9faf7d2549527b57d34884efbe9408d28..8fc889f31be72822c673d068bd2dd2d688a0af6f 100644 Binary files a/packages/desktop-client/e2e/transactions.test.js-snapshots/Transactions-filters-transactions-by-category-3-chromium-linux.png and b/packages/desktop-client/e2e/transactions.test.js-snapshots/Transactions-filters-transactions-by-category-3-chromium-linux.png differ diff --git a/packages/desktop-client/src/components/filters/FilterExpression.tsx b/packages/desktop-client/src/components/filters/FilterExpression.tsx index d649fd09c3f7375469af5f2c461ac6748bc7dd8f..34df7694a76bd11a8b0c29aa043d0272562a7d26 100644 --- a/packages/desktop-client/src/components/filters/FilterExpression.tsx +++ b/packages/desktop-client/src/components/filters/FilterExpression.tsx @@ -79,7 +79,8 @@ export function FilterExpression<T extends RuleConditionEntity>({ valueIsRaw={ op === 'contains' || op === 'matches' || - op === 'doesNotContain' + op === 'doesNotContain' || + op === 'hasTags' } /> </> diff --git a/packages/desktop-client/src/components/filters/FiltersMenu.jsx b/packages/desktop-client/src/components/filters/FiltersMenu.jsx index 61720dff6dd019669b6b458d47abbd6add023676..8baacffe99ebe8901edbc2ff5c01529baf3bc5dc 100644 --- a/packages/desktop-client/src/components/filters/FiltersMenu.jsx +++ b/packages/desktop-client/src/components/filters/FiltersMenu.jsx @@ -201,7 +201,10 @@ function ConfigureField({ subfield={subfield} type={ type === 'id' && - (op === 'contains' || op === 'matches' || op === 'doesNotContain') + (op === 'contains' || + op === 'matches' || + op === 'doesNotContain' || + op === 'hasTags') ? 'string' : type } diff --git a/packages/desktop-client/src/components/filters/updateFilterReducer.ts b/packages/desktop-client/src/components/filters/updateFilterReducer.ts index 5cb3727367e5e28702d4e73ada2747a474a51574..4f6286f83834157ca5fbc7e250f4e1b05f48bd80 100644 --- a/packages/desktop-client/src/components/filters/updateFilterReducer.ts +++ b/packages/desktop-client/src/components/filters/updateFilterReducer.ts @@ -18,7 +18,8 @@ export function updateFilterReducer( action.op === 'matches' || action.op === 'is' || action.op === 'doesNotContain' || - action.op === 'isNot') + action.op === 'isNot' || + action.op === 'hasTags') ) { // Clear out the value if switching between contains or // is/oneof for the id or string type diff --git a/packages/desktop-client/src/components/modals/EditRule.jsx b/packages/desktop-client/src/components/modals/EditRule.jsx index b896846fcfe90f325f57c81a2af6cf9534debcb7..c36e331dc95c9dc1fc51232e86ed082666c9bdde 100644 --- a/packages/desktop-client/src/components/modals/EditRule.jsx +++ b/packages/desktop-client/src/components/modals/EditRule.jsx @@ -116,7 +116,7 @@ export function OpSelect({ // TODO: Add matches op support for payees, accounts, categories. .filter(op => type === 'id' - ? !['contains', 'matches', 'doesNotContain'].includes(op) + ? !['contains', 'matches', 'doesNotContain', 'hasTags'].includes(op) : true, ) .map(op => [op, formatOp(op, type)]); diff --git a/packages/desktop-client/src/components/reports/reports/CustomReport.tsx b/packages/desktop-client/src/components/reports/reports/CustomReport.tsx index c0ed8630347958b7d6d8ee91d8e7b332192f90df..df09f6eed2715d15e303e43fc6f9d65a5bf25904 100644 --- a/packages/desktop-client/src/components/reports/reports/CustomReport.tsx +++ b/packages/desktop-client/src/components/reports/reports/CustomReport.tsx @@ -155,7 +155,7 @@ export function CustomReport() { !!conditions.find( ({ field, op }) => field === 'category' && - ['contains', 'doesNotContain', 'matches'].includes(op), + ['contains', 'doesNotContain', 'matches', 'hasTags'].includes(op), ) || conditions.filter(({ field }) => field === 'category').length >= 2; const setSelectedCategories = (newCategories: CategoryEntity[]) => { diff --git a/packages/desktop-client/src/components/transactions/TransactionList.jsx b/packages/desktop-client/src/components/transactions/TransactionList.jsx index 9b3eb7895fb7293cc8206b3f9b027365965e2372..14fee62c9a5759e61b7733cf31f1f853970bd2fb 100644 --- a/packages/desktop-client/src/components/transactions/TransactionList.jsx +++ b/packages/desktop-client/src/components/transactions/TransactionList.jsx @@ -1,8 +1,6 @@ import React, { useRef, useCallback, useLayoutEffect } from 'react'; import { useDispatch } from 'react-redux'; -import escapeRegExp from 'lodash/escapeRegExp'; - import { pushModal } from 'loot-core/client/actions'; import { send } from 'loot-core/src/platform/client/fetch'; import { @@ -191,8 +189,8 @@ export function TransactionList({ const onNotesTagClick = useCallback(tag => { onApplyFilter({ field: 'notes', - op: 'matches', - value: `(^|\\s|\\w)${escapeRegExp(tag)}($|\\s|#)`, + op: 'hasTags', + value: tag, type: 'string', }); }); diff --git a/packages/loot-core/src/server/accounts/rules.ts b/packages/loot-core/src/server/accounts/rules.ts index 467dc7bc80133d5fa7aef6ff42dfee71e1600d2b..e97fa8ef2f39b67f809f673fa843586ebcccc7fa 100644 --- a/packages/loot-core/src/server/accounts/rules.ts +++ b/packages/loot-core/src/server/accounts/rules.ts @@ -147,6 +147,46 @@ const CONDITION_TYPES = { }, }, string: { + ops: [ + 'is', + 'contains', + 'matches', + 'oneOf', + 'isNot', + 'doesNotContain', + 'notOneOf', + 'hasTags', + ], + nullable: true, + parse(op, value, fieldName) { + if (op === 'oneOf' || op === 'notOneOf') { + assert( + Array.isArray(value), + 'no-empty-array', + `oneOf must have an array value (field: ${fieldName}): ${JSON.stringify( + value, + )}`, + ); + return value.filter(Boolean).map(val => val.toLowerCase()); + } + + if ( + op === 'contains' || + op === 'matches' || + op === 'doesNotContain' || + op === 'hasTags' + ) { + assert( + typeof value === 'string' && value.length > 0, + 'no-empty-string', + `contains must have non-empty string (field: ${fieldName})`, + ); + } + + return value.toLowerCase(); + }, + }, + imported_payee: { ops: [ 'is', 'contains', @@ -379,6 +419,13 @@ export class Condition { return false; } return this.value.indexOf(fieldValue) !== -1; + + case 'hasTags': + if (fieldValue === null) { + return false; + } + return fieldValue.indexOf(this.value) !== -1; + case 'notOneOf': if (fieldValue === null) { return false; @@ -863,6 +910,7 @@ const OP_SCORES: Record<RuleConditionEntity['op'], number> = { contains: 0, doesNotContain: 0, matches: 0, + hasTags: 0, }; function computeScore(rule) { diff --git a/packages/loot-core/src/server/accounts/transaction-rules.ts b/packages/loot-core/src/server/accounts/transaction-rules.ts index 66024acf8d0ca1166e6f7bd94b552d2c6997423e..e22697ea40789e75e851cdee394f197ebb051ad4 100644 --- a/packages/loot-core/src/server/accounts/transaction-rules.ts +++ b/packages/loot-core/src/server/accounts/transaction-rules.ts @@ -472,6 +472,19 @@ export function conditionsToAQL(conditions, { recurDateBounds = 100 } = {}) { return { id: null }; } return { $or: values.map(v => apply(field, '$eq', v)) }; + + case 'hasTags': + const tagValues = value + .split(/(?<!#)(#[\w\d\p{Emoji}-]+)(?=\s|$)/gu) + .filter(tag => tag.startsWith('#')); + + return { + $and: tagValues.map(v => { + const regex = new RegExp(`(^|\\s)${v}(\\s|$)`); + return apply(field, '$regexp', regex.source); + }), + }; + case 'notOneOf': const notValues = value; if (notValues.length === 0) { diff --git a/packages/loot-core/src/shared/rules.ts b/packages/loot-core/src/shared/rules.ts index b920d1b0cb849072a2bab293285bd07e498fe775..76eaa2926054b643cc5a5139bbb071a0f4427f02 100644 --- a/packages/loot-core/src/shared/rules.ts +++ b/packages/loot-core/src/shared/rules.ts @@ -17,6 +17,7 @@ export const TYPE_INFO = { 'isNot', 'doesNotContain', 'notOneOf', + 'hasTags', ], nullable: true, }, @@ -25,6 +26,19 @@ export const TYPE_INFO = { nullable: false, }, string: { + ops: [ + 'is', + 'contains', + 'matches', + 'oneOf', + 'isNot', + 'doesNotContain', + 'notOneOf', + 'hasTags', + ], + nullable: true, + }, + imported_payee: { ops: [ 'is', 'contains', @@ -48,7 +62,7 @@ export const TYPE_INFO = { export const FIELD_TYPES = new Map( Object.entries({ - imported_payee: 'string', + imported_payee: 'imported_payee', payee: 'id', date: 'date', notes: 'string', @@ -107,6 +121,8 @@ export function friendlyOp(op, type?) { return 'is between'; case 'contains': return 'contains'; + case 'hasTags': + return 'has tag(s)'; case 'matches': return 'matches'; case 'doesNotContain': diff --git a/packages/loot-core/src/types/models/rule.d.ts b/packages/loot-core/src/types/models/rule.d.ts index 3c61ebf70a39d662eedea9f8de491348377c40a0..bacb94ca5520dbe5a83bba02e9cfc80c94878f64 100644 --- a/packages/loot-core/src/types/models/rule.d.ts +++ b/packages/loot-core/src/types/models/rule.d.ts @@ -25,6 +25,7 @@ export type RuleConditionOp = | 'lte' | 'contains' | 'doesNotContain' + | 'hasTags' | 'matches'; type FieldValueTypes = { @@ -96,6 +97,7 @@ export type RuleConditionEntity = | 'contains' | 'doesNotContain' | 'matches' + | 'hasTags' > | BaseConditionEntity< 'payee', diff --git a/upcoming-release-notes/3290.md b/upcoming-release-notes/3290.md new file mode 100644 index 0000000000000000000000000000000000000000..5cbcce408cdfc1ea20ee20fe09f22ef79adb897a --- /dev/null +++ b/upcoming-release-notes/3290.md @@ -0,0 +1,6 @@ +--- +category: Enhancements +authors: [lelemm] +--- + +Add new 'has tag(s)' filter to filter note tags.