From a3e997137919c8784384e409558c3861481a5237 Mon Sep 17 00:00:00 2001
From: Neil <55785687+carkom@users.noreply.github.com>
Date: Thu, 20 Jul 2023 17:36:37 +0100
Subject: [PATCH] Add "not" checkbox for string filters (#1287)

---
 .../e2e/page-models/rules-page.js             |  2 +-
 .../src/components/ManageRules.js             |  2 +-
 .../src/components/common/Select.tsx          | 55 +++++++++++---
 .../src/components/filters/FiltersMenu.js     | 76 ++++++++++++++-----
 .../src/components/modals/EditRule.js         | 22 +++++-
 .../loot-core/src/server/accounts/rules.ts    | 61 ++++++++++++---
 .../src/server/accounts/transaction-rules.ts  | 23 +++++-
 packages/loot-core/src/server/aql/compiler.ts | 25 ++++++
 packages/loot-core/src/shared/rules.ts        | 10 ++-
 upcoming-release-notes/1287.md                |  6 ++
 10 files changed, 233 insertions(+), 49 deletions(-)
 create mode 100644 upcoming-release-notes/1287.md

diff --git a/packages/desktop-client/e2e/page-models/rules-page.js b/packages/desktop-client/e2e/page-models/rules-page.js
index 17d36257b..31926bcec 100644
--- a/packages/desktop-client/e2e/page-models/rules-page.js
+++ b/packages/desktop-client/e2e/page-models/rules-page.js
@@ -81,7 +81,7 @@ export class RulesPage {
 
       if (op) {
         await row.getByRole('button', { name: 'is' }).click();
-        await this.page.getByRole('option', { name: op }).click();
+        await this.page.getByRole('option', { name: op, exact: true }).click();
       }
 
       if (value) {
diff --git a/packages/desktop-client/src/components/ManageRules.js b/packages/desktop-client/src/components/ManageRules.js
index 5a26a7bd5..5f724971e 100644
--- a/packages/desktop-client/src/components/ManageRules.js
+++ b/packages/desktop-client/src/components/ManageRules.js
@@ -548,7 +548,7 @@ function ruleToString(rule, data) {
   let conditions = rule.conditions.flatMap(cond => [
     mapField(cond.field),
     friendlyOp(cond.op),
-    cond.op === 'oneOf'
+    cond.op === 'oneOf' || cond.op === 'notOneOf'
       ? cond.value.map(v => mapValue(cond.field, v, data)).join(', ')
       : mapValue(cond.field, cond.value, data),
   ]);
diff --git a/packages/desktop-client/src/components/common/Select.tsx b/packages/desktop-client/src/components/common/Select.tsx
index 8f9b85e88..f328d8479 100644
--- a/packages/desktop-client/src/components/common/Select.tsx
+++ b/packages/desktop-client/src/components/common/Select.tsx
@@ -8,6 +8,7 @@ import {
 import { type CSSProperties, css } from 'glamor';
 
 import ExpandArrow from '../../icons/v0/ExpandArrow';
+import { colors } from '../../style';
 
 type CustomSelectProps = {
   options: Array<[string, string]>;
@@ -16,6 +17,7 @@ type CustomSelectProps = {
   onChange?: (newValue: string) => void;
   style?: CSSProperties;
   wrapperStyle?: CSSProperties;
+  line: number;
   disabledKeys?: string[];
 };
 
@@ -41,6 +43,7 @@ export default function Select({
   onChange,
   style,
   wrapperStyle,
+  line,
   disabledKeys = [],
 }: CustomSelectProps) {
   const arrowSize = 7;
@@ -73,17 +76,47 @@ export default function Select({
         </span>
       </ListboxButton>
       <ListboxPopover style={{ zIndex: 10000, outline: 0, borderRadius: 4 }}>
-        <ListboxList style={{ maxHeight: 250, overflowY: 'auto' }}>
-          {options.map(([value, label]) => (
-            <ListboxOption
-              key={value}
-              value={value}
-              disabled={disabledKeys.includes(value)}
-            >
-              {label}
-            </ListboxOption>
-          ))}
-        </ListboxList>
+        {!line ? (
+          <ListboxList style={{ maxHeight: 250, overflowY: 'auto' }}>
+            {options.map(([value, label]) => (
+              <ListboxOption
+                key={value}
+                value={value}
+                disabled={disabledKeys.includes(value)}
+              >
+                {label}
+              </ListboxOption>
+            ))}
+          </ListboxList>
+        ) : (
+          <ListboxList style={{ maxHeight: 250, overflowY: 'auto' }}>
+            {options.slice(0, line).map(([value, label]) => (
+              <ListboxOption
+                key={value}
+                value={value}
+                disabled={disabledKeys.includes(value)}
+              >
+                {label}
+              </ListboxOption>
+            ))}
+            <div
+              style={{
+                padding: '2px',
+                marginTop: 5,
+                borderTop: '1px solid ' + colors.n10,
+              }}
+            />
+            {options.slice(line, options.length).map(([value, label]) => (
+              <ListboxOption
+                key={value}
+                value={value}
+                disabled={disabledKeys.includes(value)}
+              >
+                {label}
+              </ListboxOption>
+            ))}
+          </ListboxList>
+        )}
       </ListboxPopover>
     </ListboxInput>
   );
diff --git a/packages/desktop-client/src/components/filters/FiltersMenu.js b/packages/desktop-client/src/components/filters/FiltersMenu.js
index c44d39ab0..cea87325f 100644
--- a/packages/desktop-client/src/components/filters/FiltersMenu.js
+++ b/packages/desktop-client/src/components/filters/FiltersMenu.js
@@ -109,9 +109,15 @@ function updateFilterReducer(state, action) {
     case 'set-op': {
       let type = FIELD_TYPES.get(state.field);
       let value = state.value;
-      if (type === 'id' && action.op === 'contains') {
-        // Clear out the value if switching between contains for
-        // the id type
+      if (
+        (type === 'id' || type === 'string') &&
+        (action.op === 'contains' ||
+          action.op === 'is' ||
+          action.op === 'doesNotContain' ||
+          action.op === 'isNot')
+      ) {
+        // Clear out the value if switching between contains or
+        // is/oneof for the id or string type
         value = null;
       }
       return { ...state, op: action.op, value };
@@ -159,7 +165,7 @@ function ConfigureField({
     <Tooltip
       position="bottom-left"
       style={{ padding: 15 }}
-      width={250}
+      width={275}
       onClose={() => dispatch({ type: 'close' })}
     >
       <FocusScope>
@@ -232,14 +238,40 @@ function ConfigureField({
                   }}
                 />,
               ]
-            : ops.map(currOp => (
-                <OpButton
-                  key={currOp}
-                  op={currOp}
-                  selected={currOp === op}
-                  onClick={() => dispatch({ type: 'set-op', op: currOp })}
-                />
-              ))}
+            : [
+                <Stack
+                  direction="row"
+                  align="flex-start"
+                  spacing={1}
+                  style={{ flexWrap: 'wrap' }}
+                >
+                  {ops.slice(0, 3).map(currOp => (
+                    <OpButton
+                      key={currOp}
+                      op={currOp}
+                      selected={currOp === op}
+                      onClick={() => dispatch({ type: 'set-op', op: currOp })}
+                    />
+                  ))}
+                </Stack>,
+                <Stack
+                  direction="row"
+                  align="flex-start"
+                  spacing={1}
+                  style={{ flexWrap: 'wrap' }}
+                >
+                  {ops.slice(3, ops.length).map(currOp => (
+                    <View>
+                      <OpButton
+                        key={currOp}
+                        op={currOp}
+                        selected={currOp === op}
+                        onClick={() => dispatch({ type: 'set-op', op: currOp })}
+                      />
+                    </View>
+                  ))}
+                </Stack>,
+              ]}
         </Stack>
 
         <form action="#">
@@ -248,19 +280,27 @@ function ConfigureField({
               inputRef={inputRef}
               field={field}
               subfield={subfield}
-              type={type === 'id' && op === 'contains' ? 'string' : type}
+              type={
+                type === 'id' && (op === 'contains' || op === 'doesNotContain')
+                  ? 'string'
+                  : type
+              }
               value={value}
-              multi={op === 'oneOf'}
+              multi={op === 'oneOf' || op === 'notOneOf'}
               style={{ marginTop: 10 }}
               onChange={v => dispatch({ type: 'set-value', value: v })}
             />
           )}
 
-          <Stack direction="row" justify="flex-end" align="center">
+          <Stack
+            direction="row"
+            justify="flex-end"
+            align="center"
+            style={{ marginTop: 15 }}
+          >
             <View style={{ flex: 1 }} />
             <Button
               primary
-              style={{ marginTop: 15 }}
               onClick={e => {
                 e.preventDefault();
                 onApply({
@@ -472,12 +512,12 @@ function FilterExpression({
               <Text style={{ color: colors.p4 }}>
                 {mapField(field, options)}
               </Text>{' '}
-              <Text style={{ color: colors.n3 }}>{friendlyOp(op)}</Text>{' '}
+              <Text style={{ color: colors.n3 }}>{friendlyOp(op, null)}</Text>{' '}
               <Value
                 value={value}
                 field={field}
                 inline={true}
-                valueIsRaw={op === 'contains'}
+                valueIsRaw={op === 'contains' || op === 'doesNotContain'}
               />
             </>
           )}
diff --git a/packages/desktop-client/src/components/modals/EditRule.js b/packages/desktop-client/src/components/modals/EditRule.js
index c9733abc7..c3b40802d 100644
--- a/packages/desktop-client/src/components/modals/EditRule.js
+++ b/packages/desktop-client/src/components/modals/EditRule.js
@@ -91,10 +91,15 @@ export function OpSelect({
   formatOp = friendlyOp,
   onChange,
 }) {
+  let line;
   // We don't support the `contains` operator for the id type for
   // rules yet
   if (type === 'id') {
-    ops = ops.filter(op => op !== 'contains');
+    ops = ops.filter(op => op !== 'contains' && op !== 'doesNotContain');
+    line = ops.length / 2;
+  }
+  if (type === 'string') {
+    line = ops.length / 2;
   }
 
   return (
@@ -102,6 +107,7 @@ export function OpSelect({
       options={ops.map(op => [op, formatOp(op, type)])}
       value={value}
       onChange={value => onChange('op', value)}
+      line={line}
       style={style}
     />
   );
@@ -200,7 +206,7 @@ function ConditionEditor({
         field={field}
         type={type}
         value={value}
-        multi={op === 'oneOf'}
+        multi={op === 'oneOf' || op === 'notOneOf'}
         onChange={v => onChange('value', v)}
       />
     );
@@ -471,14 +477,22 @@ function ConditionsList({
           // Switching between oneOf and other operators is a
           // special-case. It changes the input type, so we need to
           // clear the value
-          if (cond.op !== 'oneOf' && op === 'oneOf') {
+          if (
+            cond.op !== 'oneOf' &&
+            cond.op !== 'notOneOf' &&
+            (op === 'oneOf' || op === 'notOneOf')
+          ) {
             return newInput(
               makeValue(cond.value != null ? [cond.value] : [], {
                 ...cond,
                 op: value,
               }),
             );
-          } else if (cond.op === 'oneOf' && op !== 'oneOf') {
+          } else if (
+            (cond.op === 'oneOf' || cond.op === 'notOneOf') &&
+            op !== 'oneOf' &&
+            op !== 'notOneOf'
+          ) {
             return newInput(
               makeValue(cond.value.length > 0 ? cond.value[0] : null, {
                 ...cond,
diff --git a/packages/loot-core/src/server/accounts/rules.ts b/packages/loot-core/src/server/accounts/rules.ts
index d79ef88f8..87203e2c9 100644
--- a/packages/loot-core/src/server/accounts/rules.ts
+++ b/packages/loot-core/src/server/accounts/rules.ts
@@ -109,10 +109,10 @@ let CONDITION_TYPES = {
     },
   },
   id: {
-    ops: ['is', 'contains', 'oneOf'],
+    ops: ['is', 'contains', 'oneOf', 'isNot', 'doesNotContain', 'notOneOf'],
     nullable: true,
     parse(op, value, fieldName) {
-      if (op === 'oneOf') {
+      if (op === 'oneOf' || op === 'notOneOf') {
         assert(
           Array.isArray(value),
           'no-empty-array',
@@ -124,10 +124,10 @@ let CONDITION_TYPES = {
     },
   },
   string: {
-    ops: ['is', 'contains', 'oneOf'],
+    ops: ['is', 'contains', 'oneOf', 'isNot', 'doesNotContain', 'notOneOf'],
     nullable: false,
     parse(op, value, fieldName) {
-      if (op === 'oneOf') {
+      if (op === 'oneOf' || op === 'notOneOf') {
         assert(
           Array.isArray(value),
           'no-empty-array',
@@ -138,7 +138,7 @@ let CONDITION_TYPES = {
         return value.filter(Boolean).map(val => val.toLowerCase());
       }
 
-      if (op === 'contains') {
+      if (op === 'contains' || op === 'doesNotContain') {
         assert(
           typeof value === 'string' && value.length > 0,
           'no-empty-string',
@@ -323,8 +323,10 @@ export class Condition {
           }
           return fieldValue === number;
         }
-
         return fieldValue === this.value;
+
+      case 'isNot':
+        return fieldValue !== this.value;
       case 'isbetween': {
         // The parsing logic already checks that the value is of the
         // right type (only numbers with high and low)
@@ -336,11 +338,21 @@ export class Condition {
           return false;
         }
         return fieldValue.indexOf(this.value) !== -1;
+      case 'doesNotContain':
+        if (fieldValue === null) {
+          return false;
+        }
+        return fieldValue.indexOf(this.value) === -1;
       case 'oneOf':
         if (fieldValue === null) {
           return false;
         }
         return this.value.indexOf(fieldValue) !== -1;
+      case 'notOneOf':
+        if (fieldValue === null) {
+          return false;
+        }
+        return this.value.indexOf(fieldValue) === -1;
       case 'gt':
         if (fieldValue === null) {
           return false;
@@ -568,8 +580,14 @@ export class RuleIndexer {
     let cond = rule.conditions.find(cond => cond.field === this.field);
     let indexes = [];
 
-    if (cond && (cond.op === 'oneOf' || cond.op === 'is')) {
-      if (cond.op === 'oneOf') {
+    if (
+      cond &&
+      (cond.op === 'oneOf' ||
+        cond.op === 'is' ||
+        cond.op === 'isNot' ||
+        cond.op === 'notOneOf')
+    ) {
+      if (cond.op === 'oneOf' || cond.op === 'notOneOf') {
         cond.value.forEach(val => indexes.push(this.getIndexForValue(val)));
       } else {
         indexes.push(this.getIndexForValue(cond.value));
@@ -635,7 +653,12 @@ function computeScore(rule) {
 
   if (
     rule.conditions.every(
-      cond => cond.op === 'is' || cond.op === 'isapprox' || cond.op === 'oneOf',
+      cond =>
+        cond.op === 'is' ||
+        cond.op === 'isNot' ||
+        cond.op === 'isapprox' ||
+        cond.op === 'oneOf' ||
+        cond.op === 'notOneOf',
     )
   ) {
     return initialScore * 2;
@@ -716,10 +739,18 @@ export function migrateIds(rule, mappings) {
           cond.value = mappings.get(cond.rawValue) || cond.rawValue;
           cond.unparsedValue = cond.value;
           break;
+        case 'isNot':
+          cond.value = mappings.get(cond.rawValue) || cond.rawValue;
+          cond.unparsedValue = cond.value;
+          break;
         case 'oneOf':
           cond.value = cond.rawValue.map(v => mappings.get(v) || v);
           cond.unparsedValue = [...cond.value];
           break;
+        case 'notOneOf':
+          cond.value = cond.rawValue.map(v => mappings.get(v) || v);
+          cond.unparsedValue = [...cond.value];
+          break;
         default:
       }
     }
@@ -750,6 +781,11 @@ export function iterateIds(rules, fieldName, func) {
               continue ruleiter;
             }
             break;
+          case 'isNot':
+            if (func(rule, cond.value)) {
+              continue ruleiter;
+            }
+            break;
           case 'oneOf':
             for (let vi = 0; vi < cond.value.length; vi++) {
               if (func(rule, cond.value[vi])) {
@@ -757,6 +793,13 @@ export function iterateIds(rules, fieldName, func) {
               }
             }
             break;
+          case 'notOneOf':
+            for (let vi = 0; vi < cond.value.length; vi++) {
+              if (func(rule, cond.value[vi])) {
+                continue ruleiter;
+              }
+            }
+            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 9190bc589..56cc6a73a 100644
--- a/packages/loot-core/src/server/accounts/transaction-rules.ts
+++ b/packages/loot-core/src/server/accounts/transaction-rules.ts
@@ -416,8 +416,9 @@ export function conditionsToAQL(conditions, { recurDateBounds = 100 } = {}) {
           }
           return apply(field, '$eq', number);
         }
-
         return apply(field, '$eq', value);
+      case 'isNot':
+        return apply(field, '$ne', value);
 
       case 'isbetween':
         // This operator is only applicable to the specific `between`
@@ -434,6 +435,14 @@ export function conditionsToAQL(conditions, { recurDateBounds = 100 } = {}) {
           '$like',
           '%' + value + '%',
         );
+      case 'doesNotContain':
+        // Running contains with id will automatically reach into
+        // the `name` of the referenced table and do a string match
+        return apply(
+          type === 'id' ? field + '.name' : field,
+          '$notlike',
+          '%' + value + '%',
+        );
       case 'oneOf':
         let values = value;
         if (values.length === 0) {
@@ -441,6 +450,13 @@ export function conditionsToAQL(conditions, { recurDateBounds = 100 } = {}) {
           return { id: null };
         }
         return { $or: values.map(v => apply(field, '$eq', v)) };
+      case 'notOneOf':
+        let notValues = value;
+        if (notValues.length === 0) {
+          // This forces it to match nothing
+          return { id: null };
+        }
+        return { $and: notValues.map(v => apply(field, '$ne', v)) };
       case 'gt':
         return apply(field, '$gt', getValue(value));
       case 'gte':
@@ -527,7 +543,7 @@ function* getIsSetterRules(
       rule.actions[0].field === actionField &&
       (actionValue === undefined || rule.actions[0].value === actionValue) &&
       rule.conditions.length === 1 &&
-      rule.conditions[0].op === 'is' &&
+      (rule.conditions[0].op === 'is' || rule.conditions[0].op === 'isNot') &&
       rule.conditions[0].field === condField &&
       (condValue === undefined || rule.conditions[0].value === condValue)
     ) {
@@ -555,7 +571,8 @@ function* getOneOfSetterRules(
       rule.actions[0].field === actionField &&
       (actionValue == null || rule.actions[0].value === actionValue) &&
       rule.conditions.length === 1 &&
-      rule.conditions[0].op === 'oneOf' &&
+      (rule.conditions[0].op === 'oneOf' ||
+        rule.conditions[0].op === 'oneOf') &&
       rule.conditions[0].field === condField &&
       (condValue == null || rule.conditions[0].value.indexOf(condValue) !== -1)
     ) {
diff --git a/packages/loot-core/src/server/aql/compiler.ts b/packages/loot-core/src/server/aql/compiler.ts
index 4898e9715..03281504e 100644
--- a/packages/loot-core/src/server/aql/compiler.ts
+++ b/packages/loot-core/src/server/aql/compiler.ts
@@ -674,6 +674,27 @@ const compileOp = saveStack('op', (state, fieldRef, opData) => {
 
       return `${left} = ${right}`;
     }
+    case '$ne': {
+      if (castInput(state, rhs, lhs.type).type === 'null') {
+        return `${val(state, lhs)} IS NULL`;
+      }
+
+      let [left, right] = valArray(state, [lhs, rhs], [null, lhs.type]);
+
+      if (rhs.type === 'param') {
+        let orders = state.namedParameters.map(param => {
+          return param === rhs || param === lhs ? [param, { ...param }] : param;
+        });
+        state.namedParameters = [].concat.apply([], orders);
+
+        return `CASE
+          WHEN ${left} IS NULL THEN ${right} IS NULL
+          ELSE ${left} != ${right}
+        END`;
+      }
+
+      return `${left} != ${right}`;
+    }
     case '$oneof': {
       let [left, right] = valArray(state, [lhs, rhs], [null, 'array']);
       // Dedupe the ids
@@ -685,6 +706,10 @@ const compileOp = saveStack('op', (state, fieldRef, opData) => {
       let [left, right] = valArray(state, [lhs, rhs], ['string', 'string']);
       return `${left} LIKE ${right}`;
     }
+    case '$notlike': {
+      let [left, right] = valArray(state, [lhs, rhs], ['string', 'string']);
+      return `(${left} NOT LIKE ${right}\n OR ${left} IS NULL)`;
+    }
     default:
       throw new CompileError(`Unknown operator: ${op}`);
   }
diff --git a/packages/loot-core/src/shared/rules.ts b/packages/loot-core/src/shared/rules.ts
index efcf73da0..aa6fb843c 100644
--- a/packages/loot-core/src/shared/rules.ts
+++ b/packages/loot-core/src/shared/rules.ts
@@ -8,7 +8,7 @@ export const TYPE_INFO = {
     nullable: false,
   },
   id: {
-    ops: ['is', 'contains', 'oneOf'],
+    ops: ['is', 'contains', 'oneOf', 'isNot', 'doesNotContain', 'notOneOf'],
     nullable: true,
   },
   saved: {
@@ -16,7 +16,7 @@ export const TYPE_INFO = {
     nullable: false,
   },
   string: {
-    ops: ['is', 'contains', 'oneOf'],
+    ops: ['is', 'contains', 'oneOf', 'isNot', 'doesNotContain', 'notOneOf'],
     nullable: false,
   },
   number: {
@@ -71,14 +71,20 @@ export function friendlyOp(op, type) {
   switch (op) {
     case 'oneOf':
       return 'one of';
+    case 'notOneOf':
+      return 'not one of';
     case 'is':
       return 'is';
+    case 'isNot':
+      return 'is not';
     case 'isapprox':
       return 'is approx';
     case 'isbetween':
       return 'is between';
     case 'contains':
       return 'contains';
+    case 'doesNotContain':
+      return 'does not contain';
     case 'gt':
       if (type === 'date') {
         return 'is after';
diff --git a/upcoming-release-notes/1287.md b/upcoming-release-notes/1287.md
new file mode 100644
index 000000000..fd0048fb9
--- /dev/null
+++ b/upcoming-release-notes/1287.md
@@ -0,0 +1,6 @@
+---
+category: Enhancements
+authors: [carkom]
+---
+
+Added a negate options to the filters that are string based fields. This was added to Accounts page filters as well as the rules modal.
\ No newline at end of file
-- 
GitLab