Feature/group filters by type (#922)

* Add groups to activities filter component

* Update changelog
This commit is contained in:
Thomas Kaul 2022-05-15 21:51:31 +02:00 committed by GitHub
parent f1483569a2
commit 160335302a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 130 additions and 78 deletions

View File

@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## Unreleased
### Added
- Added groups to the activities filter component
## 1.148.0 - 14.05.2022 ## 1.148.0 - 14.05.2022
### Added ### Added

View File

@ -120,7 +120,7 @@ export class AccountService {
where.id = { where.id = {
in: filters in: filters
.filter(({ type }) => { .filter(({ type }) => {
return type === 'account'; return type === 'ACCOUNT';
}) })
.map(({ id }) => { .map(({ id }) => {
return id; return id;

View File

@ -188,7 +188,7 @@ export class OrderService {
}): Promise<Activity[]> { }): Promise<Activity[]> {
const where: Prisma.OrderWhereInput = { userId }; const where: Prisma.OrderWhereInput = { userId };
const { account: filtersByAccount, tag: filtersByTag } = groupBy( const { ACCOUNT: filtersByAccount, TAG: filtersByTag } = groupBy(
filters, filters,
(filter) => { (filter) => {
return filter.type; return filter.type;

View File

@ -119,13 +119,13 @@ export class PortfolioController {
...accountIds.map((accountId) => { ...accountIds.map((accountId) => {
return <Filter>{ return <Filter>{
id: accountId, id: accountId,
type: 'account' type: 'ACCOUNT'
}; };
}), }),
...tagIds.map((tagId) => { ...tagIds.map((tagId) => {
return <Filter>{ return <Filter>{
id: tagId, id: tagId,
type: 'tag' type: 'TAG'
}; };
}) })
]; ];

View File

@ -165,7 +165,7 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
return { return {
id, id,
label: name, label: name,
type: 'account' type: 'ACCOUNT'
}; };
}); });
@ -173,7 +173,7 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
return { return {
id, id,
label: name, label: name,
type: 'tag' type: 'TAG'
}; };
}); });

View File

@ -187,7 +187,7 @@ export class DataService {
let params = new HttpParams(); let params = new HttpParams();
if (filters?.length > 0) { if (filters?.length > 0) {
const { account: filtersByAccount, tag: filtersByTag } = groupBy( const { ACCOUNT: filtersByAccount, TAG: filtersByTag } = groupBy(
filters, filters,
(filter) => { (filter) => {
return filter.type; return filter.type;

View File

@ -0,0 +1,6 @@
import { Filter } from './filter.interface';
export interface FilterGroup {
filters: Filter[];
name: Filter['type'];
}

View File

@ -1,5 +1,5 @@
export interface Filter { export interface Filter {
id: string; id: string;
label?: string; label?: string;
type: 'account' | 'tag'; type: 'ACCOUNT' | 'SYMBOL' | 'TAG';
} }

View File

@ -8,6 +8,7 @@ import {
} from './admin-market-data.interface'; } from './admin-market-data.interface';
import { Coupon } from './coupon.interface'; import { Coupon } from './coupon.interface';
import { Export } from './export.interface'; import { Export } from './export.interface';
import { FilterGroup } from './filter-group.interface';
import { Filter } from './filter.interface'; import { Filter } from './filter.interface';
import { HistoricalDataItem } from './historical-data-item.interface'; import { HistoricalDataItem } from './historical-data-item.interface';
import { InfoItem } from './info-item.interface'; import { InfoItem } from './info-item.interface';
@ -41,6 +42,7 @@ export {
Coupon, Coupon,
Export, Export,
Filter, Filter,
FilterGroup,
HistoricalDataItem, HistoricalDataItem,
InfoItem, InfoItem,
PortfolioChart, PortfolioChart,

View File

@ -26,9 +26,17 @@
#autocomplete="matAutocomplete" #autocomplete="matAutocomplete"
(optionSelected)="onSelectFilter($event)" (optionSelected)="onSelectFilter($event)"
> >
<mat-option *ngFor="let filter of filters | async" [value]="filter"> <mat-optgroup
{{ filter.label | gfSymbol }} *ngFor="let filterGroup of filterGroups$ | async"
</mat-option> [label]="filterGroup.name"
>
<mat-option
*ngFor="let filter of filterGroup.filters"
[value]="filter.id"
>
{{ filter.label | gfSymbol }}
</mat-option>
</mat-optgroup>
</mat-autocomplete> </mat-autocomplete>
<mat-spinner <mat-spinner
matSuffix matSuffix

View File

@ -17,7 +17,8 @@ import {
MatAutocompleteSelectedEvent MatAutocompleteSelectedEvent
} from '@angular/material/autocomplete'; } from '@angular/material/autocomplete';
import { MatChipInputEvent } from '@angular/material/chips'; import { MatChipInputEvent } from '@angular/material/chips';
import { Filter } from '@ghostfolio/common/interfaces'; import { Filter, FilterGroup } from '@ghostfolio/common/interfaces';
import { groupBy } from 'lodash';
import { BehaviorSubject, Observable, Subject } from 'rxjs'; import { BehaviorSubject, Observable, Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; import { takeUntil } from 'rxjs/operators';
@ -37,6 +38,7 @@ export class ActivitiesFilterComponent implements OnChanges, OnDestroy {
@ViewChild('autocomplete') matAutocomplete: MatAutocomplete; @ViewChild('autocomplete') matAutocomplete: MatAutocomplete;
@ViewChild('searchInput') searchInput: ElementRef<HTMLInputElement>; @ViewChild('searchInput') searchInput: ElementRef<HTMLInputElement>;
public filterGroups$: Subject<FilterGroup[]> = new BehaviorSubject([]);
public filters$: Subject<Filter[]> = new BehaviorSubject([]); public filters$: Subject<Filter[]> = new BehaviorSubject([]);
public filters: Observable<Filter[]> = this.filters$.asObservable(); public filters: Observable<Filter[]> = this.filters$.asObservable();
public searchControl = new FormControl(); public searchControl = new FormControl();
@ -50,40 +52,27 @@ export class ActivitiesFilterComponent implements OnChanges, OnDestroy {
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe((filterOrSearchTerm: Filter | string) => { .subscribe((filterOrSearchTerm: Filter | string) => {
if (filterOrSearchTerm) { if (filterOrSearchTerm) {
this.filters$.next( const searchTerm =
this.allFilters typeof filterOrSearchTerm === 'string'
.filter((filter) => { ? filterOrSearchTerm
// Filter selected filters : filterOrSearchTerm?.label;
return !this.selectedFilters.some((selectedFilter) => {
return selectedFilter.id === filter.id;
});
})
.filter((filter) => {
if (typeof filterOrSearchTerm === 'string') {
return filter.label
.toLowerCase()
.startsWith(filterOrSearchTerm.toLowerCase());
}
return filter.label this.filterGroups$.next(this.getGroupedFilters(searchTerm));
.toLowerCase() } else {
.startsWith(filterOrSearchTerm?.label?.toLowerCase()); this.filterGroups$.next(this.getGroupedFilters());
})
.sort((a, b) => a.label.localeCompare(b.label))
);
} }
}); });
} }
public ngOnChanges(changes: SimpleChanges) { public ngOnChanges(changes: SimpleChanges) {
if (changes.allFilters?.currentValue) { if (changes.allFilters?.currentValue) {
this.updateFilter(); this.updateFilters();
} }
} }
public onAddFilter({ input, value }: MatChipInputEvent): void { public onAddFilter({ input, value }: MatChipInputEvent): void {
if (value?.trim()) { if (value?.trim()) {
this.updateFilter(); this.updateFilters();
} }
// Reset the input value // Reset the input value
@ -99,12 +88,16 @@ export class ActivitiesFilterComponent implements OnChanges, OnDestroy {
return filter.id !== aFilter.id; return filter.id !== aFilter.id;
}); });
this.updateFilter(); this.updateFilters();
} }
public onSelectFilter(event: MatAutocompleteSelectedEvent): void { public onSelectFilter(event: MatAutocompleteSelectedEvent): void {
this.selectedFilters.push(event.option.value); this.selectedFilters.push(
this.updateFilter(); this.allFilters.find((filter) => {
return filter.id === event.option.value;
})
);
this.updateFilters();
this.searchInput.nativeElement.value = ''; this.searchInput.nativeElement.value = '';
this.searchControl.setValue(null); this.searchControl.setValue(null);
} }
@ -114,8 +107,8 @@ export class ActivitiesFilterComponent implements OnChanges, OnDestroy {
this.unsubscribeSubject.complete(); this.unsubscribeSubject.complete();
} }
private updateFilter() { private getGroupedFilters(searchTerm?: string) {
this.filters$.next( const filterGroupsMap = groupBy(
this.allFilters this.allFilters
.filter((filter) => { .filter((filter) => {
// Filter selected filters // Filter selected filters
@ -123,9 +116,44 @@ export class ActivitiesFilterComponent implements OnChanges, OnDestroy {
return selectedFilter.id === filter.id; return selectedFilter.id === filter.id;
}); });
}) })
.sort((a, b) => a.label.localeCompare(b.label)) .filter((filter) => {
if (searchTerm) {
// Filter by search term
return filter.label
.toLowerCase()
.includes(searchTerm.toLowerCase());
}
return filter;
})
.sort((a, b) => a.label.localeCompare(b.label)),
(filter) => {
return filter.type;
}
); );
const filterGroups: FilterGroup[] = [];
for (const type of Object.keys(filterGroupsMap)) {
filterGroups.push({
name: <Filter['type']>type,
filters: filterGroupsMap[type]
});
}
return filterGroups
.sort((a, b) => a.name.localeCompare(b.name))
.map((filterGroup) => {
return {
...filterGroup,
filters: filterGroup.filters
};
});
}
private updateFilters() {
this.filterGroups$.next(this.getGroupedFilters());
// Emit an array with a new reference // Emit an array with a new reference
this.valueChanged.emit([...this.selectedFilters]); this.valueChanged.emit([...this.selectedFilters]);
} }

View File

@ -105,17 +105,17 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy {
this.defaultDateFormat = getDateFormatString(this.locale); this.defaultDateFormat = getDateFormatString(this.locale);
if (this.activities) { if (this.activities) {
this.allFilters = this.getSearchableFieldValues(this.activities).map( this.allFilters = this.getSearchableFieldValues(this.activities);
(label) => {
return { label, id: label, type: 'tag' };
}
);
this.dataSource = new MatTableDataSource(this.activities); this.dataSource = new MatTableDataSource(this.activities);
this.dataSource.filterPredicate = (data, filter) => { this.dataSource.filterPredicate = (data, filter) => {
const dataString = this.getFilterableValues(data) const dataString = this.getFilterableValues(data)
.map((currentFilter) => {
return currentFilter.label;
})
.join(' ') .join(' ')
.toLowerCase(); .toLowerCase();
let contains = true; let contains = true;
for (const singleFilter of filter.split(SEARCH_STRING_SEPARATOR)) { for (const singleFilter of filter.split(SEARCH_STRING_SEPARATOR)) {
contains = contains =
@ -190,50 +190,51 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy {
private getFilterableValues( private getFilterableValues(
activity: OrderWithAccount, activity: OrderWithAccount,
fieldValues: Set<string> = new Set<string>() fieldValueMap: { [id: string]: Filter } = {}
): string[] { ): Filter[] {
fieldValues.add(activity.Account?.name); fieldValueMap[activity.Account?.id] = {
fieldValues.add(activity.Account?.Platform?.name); id: activity.Account?.id,
fieldValues.add(activity.SymbolProfile.currency); label: activity.Account?.name,
type: 'ACCOUNT'
};
fieldValueMap[activity.SymbolProfile.currency] = {
id: activity.SymbolProfile.currency,
label: activity.SymbolProfile.currency,
type: 'TAG'
};
if (!isUUID(activity.SymbolProfile.symbol)) { if (!isUUID(activity.SymbolProfile.symbol)) {
fieldValues.add(activity.SymbolProfile.symbol); fieldValueMap[activity.SymbolProfile.symbol] = {
id: activity.SymbolProfile.symbol,
label: activity.SymbolProfile.symbol,
type: 'SYMBOL'
};
} }
fieldValues.add(activity.type); fieldValueMap[activity.type] = {
fieldValues.add(format(activity.date, 'yyyy')); id: activity.type,
label: activity.type,
type: 'TAG'
};
return [...fieldValues].filter((item) => { fieldValueMap[format(activity.date, 'yyyy')] = {
return item !== undefined; id: format(activity.date, 'yyyy'),
}); label: format(activity.date, 'yyyy'),
type: 'TAG'
};
return Object.values(fieldValueMap);
} }
private getSearchableFieldValues(activities: OrderWithAccount[]): string[] { private getSearchableFieldValues(activities: OrderWithAccount[]): Filter[] {
const fieldValues = new Set<string>(); const fieldValueMap: { [id: string]: Filter } = {};
for (const activity of activities) { for (const activity of activities) {
this.getFilterableValues(activity, fieldValues); this.getFilterableValues(activity, fieldValueMap);
} }
return [...fieldValues] return Object.values(fieldValueMap);
.filter((item) => {
return item !== undefined;
})
.sort((a, b) => {
const aFirstChar = a.charAt(0);
const bFirstChar = b.charAt(0);
const isANumber = aFirstChar >= '0' && aFirstChar <= '9';
const isBNumber = bFirstChar >= '0' && bFirstChar <= '9';
// Sort priority: text, followed by numbers
if (isANumber && !isBNumber) {
return 1;
} else if (!isANumber && isBNumber) {
return -1;
} else {
return a.toLowerCase() < b.toLowerCase() ? -1 : 1;
}
});
} }
private getTotalFees() { private getTotalFees() {
@ -276,6 +277,7 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy {
return filter.label; return filter.label;
}) })
.join(SEARCH_STRING_SEPARATOR); .join(SEARCH_STRING_SEPARATOR);
const lowercaseSearchKeywords = filters.map((filter) => { const lowercaseSearchKeywords = filters.map((filter) => {
return filter.label.trim().toLowerCase(); return filter.label.trim().toLowerCase();
}); });