From c6a0541351a872bc0b09e8d20a0dcf6cf642ea81 Mon Sep 17 00:00:00 2001
From: Valentin Zickner <valentin.zickner@mimacom.com>
Date: Fri, 7 May 2021 18:37:08 +0200
Subject: [PATCH] add multi-filter support for transaction filtering with auto
 completion

---
 .../interfaces/order-with-account.type.ts     |   6 +-
 .../transactions-table.component.html         |  40 ++++--
 .../transactions-table.component.ts           | 126 +++++++++++++++---
 .../transactions-table.module.ts              |   8 +-
 4 files changed, 153 insertions(+), 27 deletions(-)

diff --git a/apps/api/src/app/order/interfaces/order-with-account.type.ts b/apps/api/src/app/order/interfaces/order-with-account.type.ts
index d1f5ac552..db14b6dfd 100644
--- a/apps/api/src/app/order/interfaces/order-with-account.type.ts
+++ b/apps/api/src/app/order/interfaces/order-with-account.type.ts
@@ -1,3 +1,5 @@
-import { Account, Order } from '@prisma/client';
+import { Account, Order, Platform } from '@prisma/client';
 
-export type OrderWithAccount = Order & { Account?: Account };
+type AccountWithPlatform = Account & { Platform?: Platform };
+
+export type OrderWithAccount = Order & { Account?: AccountWithPlatform };
diff --git a/apps/client/src/app/components/transactions-table/transactions-table.component.html b/apps/client/src/app/components/transactions-table/transactions-table.component.html
index 9d2165180..4af802911 100644
--- a/apps/client/src/app/components/transactions-table/transactions-table.component.html
+++ b/apps/client/src/app/components/transactions-table/transactions-table.component.html
@@ -1,12 +1,36 @@
-<mat-form-field appearance="outline" class="w-100">
-  <input
-    #input
-    autocomplete="off"
-    matInput
-    placeholder="Search for transactions..."
-    (keyup)="applyFilter($event)"
-  />
+<mat-form-field class="w-100">
   <ion-icon class="mr-1" matPrefix name="search-outline"></ion-icon>
+  <mat-chip-list #chipList aria-label="Search keywords">
+    <mat-chip
+      *ngFor="let searchKeyword of searchKeywords"
+      [removable]="true"
+      (removed)="removeKeyword(searchKeyword)"
+      matChipRemove
+    >
+      {{ searchKeyword }}
+      <ion-icon class="mr-1" matPrefix name="close-outline"></ion-icon>
+    </mat-chip>
+    <input
+      placeholder="Search for transactions..."
+      #searchInput
+      [formControl]="searchControl"
+      [matAutocomplete]="auto"
+      [matChipInputFor]="chipList"
+      [matChipInputSeparatorKeyCodes]="separatorKeysCodes"
+      (matChipInputTokenEnd)="addKeyword($event)"
+    />
+  </mat-chip-list>
+  <mat-autocomplete
+    #auto="matAutocomplete"
+    (optionSelected)="keywordSelected($event)"
+  >
+    <mat-option
+      *ngFor="let transaction of filteredTransactions | async"
+      [value]="transaction"
+    >
+      {{ transaction }}
+    </mat-option>
+  </mat-autocomplete>
 </mat-form-field>
 
 <table
diff --git a/apps/client/src/app/components/transactions-table/transactions-table.component.ts b/apps/client/src/app/components/transactions-table/transactions-table.component.ts
index 5cc3ac699..b525770fe 100644
--- a/apps/client/src/app/components/transactions-table/transactions-table.component.ts
+++ b/apps/client/src/app/components/transactions-table/transactions-table.component.ts
@@ -1,6 +1,7 @@
 import {
   ChangeDetectionStrategy,
   Component,
+  ElementRef,
   EventEmitter,
   Input,
   OnChanges,
@@ -15,10 +16,19 @@ import { MatTableDataSource } from '@angular/material/table';
 import { ActivatedRoute, Router } from '@angular/router';
 import { OrderWithAccount } from '@ghostfolio/api/app/order/interfaces/order-with-account.type';
 import { DEFAULT_DATE_FORMAT } from '@ghostfolio/helper';
-import { Subject, Subscription } from 'rxjs';
+import { BehaviorSubject, Observable, Subject, Subscription } from 'rxjs';
 import { takeUntil } from 'rxjs/operators';
 
 import { PositionDetailDialog } from '../position/position-detail-dialog/position-detail-dialog.component';
+import {
+  MatAutocomplete,
+  MatAutocompleteSelectedEvent
+} from '@angular/material/autocomplete';
+import { FormControl } from '@angular/forms';
+import { COMMA, ENTER } from '@angular/cdk/keycodes';
+import { MatChipInputEvent } from '@angular/material/chips';
+
+const SEARCH_STRING_SEPARATOR = ',';
 
 @Component({
   selector: 'gf-transactions-table',
@@ -39,13 +49,23 @@ export class TransactionsTableComponent
   @Output() transactionToUpdate = new EventEmitter<OrderWithAccount>();
 
   @ViewChild(MatSort) sort: MatSort;
+  @ViewChild('searchInput') searchInput: ElementRef<HTMLInputElement>;
+  @ViewChild('auto') matAutocomplete: MatAutocomplete;
 
   public dataSource: MatTableDataSource<OrderWithAccount> = new MatTableDataSource();
   public defaultDateFormat = DEFAULT_DATE_FORMAT;
   public displayedColumns = [];
   public isLoading = true;
   public routeQueryParams: Subscription;
+  public separatorKeysCodes: number[] = [ENTER, COMMA];
+  public searchKeywords: string[] = [];
+  public searchControl = new FormControl();
+  public filteredTransactions$: Subject<string[]> = new BehaviorSubject([]);
+  public filteredTransactions: Observable<
+    string[]
+  > = this.filteredTransactions$.asObservable();
 
+  private allFilteredTransactions: string[];
   private unsubscribeSubject = new Subject<void>();
 
   public constructor(
@@ -63,6 +83,50 @@ export class TransactionsTableComponent
           });
         }
       });
+
+    this.searchControl.valueChanges.subscribe((keyword) => {
+      if (keyword) {
+        const filterValue = keyword.toLowerCase();
+        this.filteredTransactions$.next(
+          this.allFilteredTransactions.filter(
+            (filter) => filter.toLowerCase().indexOf(filterValue) === 0
+          )
+        );
+      }
+    });
+  }
+
+  public addKeyword(event: MatChipInputEvent): void {
+    const input = event.input;
+    const value = event.value;
+
+    if ((value || '').trim()) {
+      this.searchKeywords.push(value.trim());
+      this.updateFilter();
+    }
+
+    // Reset the input value
+    if (input) {
+      input.value = '';
+    }
+
+    this.searchControl.setValue(null);
+  }
+
+  public removeKeyword(keyword: string): void {
+    const index = this.searchKeywords.indexOf(keyword);
+
+    if (index >= 0) {
+      this.searchKeywords.splice(index, 1);
+      this.updateFilter();
+    }
+  }
+
+  public keywordSelected(event: MatAutocompleteSelectedEvent): void {
+    this.searchKeywords.push(event.option.viewValue);
+    this.updateFilter();
+    this.searchInput.nativeElement.value = '';
+    this.searchControl.setValue(null);
   }
 
   public ngOnInit() {}
@@ -88,28 +152,22 @@ export class TransactionsTableComponent
     if (this.transactions) {
       this.dataSource = new MatTableDataSource(this.transactions);
       this.dataSource.filterPredicate = (data, filter) => {
-        const accumulator = (currentTerm: string, key: string) => {
-          return key === 'Account'
-            ? currentTerm + data.Account.name
-            : currentTerm + data[key];
-        };
-        const dataString = Object.keys(data)
-          .reduce(accumulator, '')
+        let dataString = TransactionsTableComponent.getFilterableValues(data)
+          .join(' ')
           .toLowerCase();
-        const transformedFilter = filter.trim().toLowerCase();
-        return dataString.includes(transformedFilter);
+        let contains = true;
+        for (const singleFilter of filter.split(SEARCH_STRING_SEPARATOR)) {
+          contains =
+            contains && dataString.includes(singleFilter.trim().toLowerCase());
+        }
+        return contains;
       };
       this.dataSource.sort = this.sort;
-
+      this.updateFilter();
       this.isLoading = false;
     }
   }
 
-  public applyFilter(event: Event) {
-    const filterValue = (event.target as HTMLInputElement).value;
-    this.dataSource.filter = filterValue.trim().toLowerCase();
-  }
-
   public onDeleteTransaction(aId: string) {
     const confirmation = confirm(
       'Do you really want to delete this transaction?'
@@ -169,4 +227,40 @@ export class TransactionsTableComponent
     this.unsubscribeSubject.next();
     this.unsubscribeSubject.complete();
   }
+
+  private updateFilter() {
+    this.dataSource.filter = this.searchKeywords.join(SEARCH_STRING_SEPARATOR);
+    const lowercaseSearchKeywords = this.searchKeywords.map((keyword) =>
+      keyword.trim().toLowerCase()
+    );
+    this.allFilteredTransactions = TransactionsTableComponent.getSearchableFieldValues(
+      this.transactions
+    ).filter((item) => {
+      return !lowercaseSearchKeywords.includes(item.trim().toLowerCase());
+    });
+    this.filteredTransactions$.next(this.allFilteredTransactions);
+  }
+
+  private static getSearchableFieldValues(
+    transactions: OrderWithAccount[]
+  ): string[] {
+    const fieldValues = new Set<string>();
+    for (const transaction of transactions) {
+      this.getFilterableValues(transaction, fieldValues);
+    }
+
+    return [...fieldValues].sort();
+  }
+
+  private static getFilterableValues(
+    transaction,
+    fieldValues: Set<string> = new Set<string>()
+  ): string[] {
+    fieldValues.add(transaction.currency);
+    fieldValues.add(transaction.symbol);
+    fieldValues.add(transaction.type);
+    fieldValues.add(transaction.Account?.name);
+    fieldValues.add(transaction.Account?.Platform?.name);
+    return [...fieldValues];
+  }
 }
diff --git a/apps/client/src/app/components/transactions-table/transactions-table.module.ts b/apps/client/src/app/components/transactions-table/transactions-table.module.ts
index 34d66e84a..f20a38df6 100644
--- a/apps/client/src/app/components/transactions-table/transactions-table.module.ts
+++ b/apps/client/src/app/components/transactions-table/transactions-table.module.ts
@@ -13,6 +13,9 @@ import { GfPositionDetailDialogModule } from '../position/position-detail-dialog
 import { GfSymbolIconModule } from '../symbol-icon/symbol-icon.module';
 import { GfValueModule } from '../value/value.module';
 import { TransactionsTableComponent } from './transactions-table.component';
+import { MatChipsModule } from '@angular/material/chips';
+import { MatAutocompleteModule } from '@angular/material/autocomplete';
+import { FormsModule, ReactiveFormsModule } from '@angular/forms';
 
 @NgModule({
   declarations: [TransactionsTableComponent],
@@ -23,13 +26,16 @@ import { TransactionsTableComponent } from './transactions-table.component';
     GfSymbolIconModule,
     GfSymbolModule,
     GfValueModule,
+    MatAutocompleteModule,
     MatButtonModule,
+    MatChipsModule,
     MatInputModule,
     MatMenuModule,
     MatSortModule,
     MatTableModule,
     NgxSkeletonLoaderModule,
-    RouterModule
+    RouterModule,
+    ReactiveFormsModule
   ],
   providers: [],
   schemas: [CUSTOM_ELEMENTS_SCHEMA]