Skip to content

Commit

Permalink
"has tags" filter (#3290)
Browse files Browse the repository at this point in the history
* new tag filter

* fixes

* release notes

* fixes for the rules modal

* more fixes

* linter

* visual regression fixes

* review suggestions

* missing this change
  • Loading branch information
lelemm authored Aug 23, 2024
1 parent 9108b63 commit 7769d03
Show file tree
Hide file tree
Showing 14 changed files with 98 additions and 10 deletions.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,8 @@ export function FilterExpression<T extends RuleConditionEntity>({
valueIsRaw={
op === 'contains' ||
op === 'matches' ||
op === 'doesNotContain'
op === 'doesNotContain' ||
op === 'hasTags'
}
/>
</>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/desktop-client/src/components/modals/EditRule.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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[]) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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',
});
});
Expand Down
48 changes: 48 additions & 0 deletions packages/loot-core/src/server/accounts/rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -863,6 +910,7 @@ const OP_SCORES: Record<RuleConditionEntity['op'], number> = {
contains: 0,
doesNotContain: 0,
matches: 0,
hasTags: 0,
};

function computeScore(rule) {
Expand Down
13 changes: 13 additions & 0 deletions packages/loot-core/src/server/accounts/transaction-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
18 changes: 17 additions & 1 deletion packages/loot-core/src/shared/rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export const TYPE_INFO = {
'isNot',
'doesNotContain',
'notOneOf',
'hasTags',
],
nullable: true,
},
Expand All @@ -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',
Expand All @@ -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',
Expand Down Expand Up @@ -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':
Expand Down
2 changes: 2 additions & 0 deletions packages/loot-core/src/types/models/rule.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export type RuleConditionOp =
| 'lte'
| 'contains'
| 'doesNotContain'
| 'hasTags'
| 'matches';

type FieldValueTypes = {
Expand Down Expand Up @@ -96,6 +97,7 @@ export type RuleConditionEntity =
| 'contains'
| 'doesNotContain'
| 'matches'
| 'hasTags'
>
| BaseConditionEntity<
'payee',
Expand Down
6 changes: 6 additions & 0 deletions upcoming-release-notes/3290.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
category: Enhancements
authors: [lelemm]
---

Add new 'has tag(s)' filter to filter note tags.

0 comments on commit 7769d03

Please sign in to comment.