Feature/group filters by type (#922)
* Add groups to activities filter component * Update changelog
This commit is contained in:
parent
f1483569a2
commit
160335302a
@ -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/),
|
||||
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
|
||||
|
||||
### Added
|
||||
|
@ -120,7 +120,7 @@ export class AccountService {
|
||||
where.id = {
|
||||
in: filters
|
||||
.filter(({ type }) => {
|
||||
return type === 'account';
|
||||
return type === 'ACCOUNT';
|
||||
})
|
||||
.map(({ id }) => {
|
||||
return id;
|
||||
|
@ -188,7 +188,7 @@ export class OrderService {
|
||||
}): Promise<Activity[]> {
|
||||
const where: Prisma.OrderWhereInput = { userId };
|
||||
|
||||
const { account: filtersByAccount, tag: filtersByTag } = groupBy(
|
||||
const { ACCOUNT: filtersByAccount, TAG: filtersByTag } = groupBy(
|
||||
filters,
|
||||
(filter) => {
|
||||
return filter.type;
|
||||
|
@ -119,13 +119,13 @@ export class PortfolioController {
|
||||
...accountIds.map((accountId) => {
|
||||
return <Filter>{
|
||||
id: accountId,
|
||||
type: 'account'
|
||||
type: 'ACCOUNT'
|
||||
};
|
||||
}),
|
||||
...tagIds.map((tagId) => {
|
||||
return <Filter>{
|
||||
id: tagId,
|
||||
type: 'tag'
|
||||
type: 'TAG'
|
||||
};
|
||||
})
|
||||
];
|
||||
|
@ -165,7 +165,7 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
|
||||
return {
|
||||
id,
|
||||
label: name,
|
||||
type: 'account'
|
||||
type: 'ACCOUNT'
|
||||
};
|
||||
});
|
||||
|
||||
@ -173,7 +173,7 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
|
||||
return {
|
||||
id,
|
||||
label: name,
|
||||
type: 'tag'
|
||||
type: 'TAG'
|
||||
};
|
||||
});
|
||||
|
||||
|
@ -187,7 +187,7 @@ export class DataService {
|
||||
let params = new HttpParams();
|
||||
|
||||
if (filters?.length > 0) {
|
||||
const { account: filtersByAccount, tag: filtersByTag } = groupBy(
|
||||
const { ACCOUNT: filtersByAccount, TAG: filtersByTag } = groupBy(
|
||||
filters,
|
||||
(filter) => {
|
||||
return filter.type;
|
||||
|
6
libs/common/src/lib/interfaces/filter-group.interface.ts
Normal file
6
libs/common/src/lib/interfaces/filter-group.interface.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { Filter } from './filter.interface';
|
||||
|
||||
export interface FilterGroup {
|
||||
filters: Filter[];
|
||||
name: Filter['type'];
|
||||
}
|
@ -1,5 +1,5 @@
|
||||
export interface Filter {
|
||||
id: string;
|
||||
label?: string;
|
||||
type: 'account' | 'tag';
|
||||
type: 'ACCOUNT' | 'SYMBOL' | 'TAG';
|
||||
}
|
||||
|
@ -8,6 +8,7 @@ import {
|
||||
} from './admin-market-data.interface';
|
||||
import { Coupon } from './coupon.interface';
|
||||
import { Export } from './export.interface';
|
||||
import { FilterGroup } from './filter-group.interface';
|
||||
import { Filter } from './filter.interface';
|
||||
import { HistoricalDataItem } from './historical-data-item.interface';
|
||||
import { InfoItem } from './info-item.interface';
|
||||
@ -41,6 +42,7 @@ export {
|
||||
Coupon,
|
||||
Export,
|
||||
Filter,
|
||||
FilterGroup,
|
||||
HistoricalDataItem,
|
||||
InfoItem,
|
||||
PortfolioChart,
|
||||
|
@ -26,9 +26,17 @@
|
||||
#autocomplete="matAutocomplete"
|
||||
(optionSelected)="onSelectFilter($event)"
|
||||
>
|
||||
<mat-option *ngFor="let filter of filters | async" [value]="filter">
|
||||
<mat-optgroup
|
||||
*ngFor="let filterGroup of filterGroups$ | async"
|
||||
[label]="filterGroup.name"
|
||||
>
|
||||
<mat-option
|
||||
*ngFor="let filter of filterGroup.filters"
|
||||
[value]="filter.id"
|
||||
>
|
||||
{{ filter.label | gfSymbol }}
|
||||
</mat-option>
|
||||
</mat-optgroup>
|
||||
</mat-autocomplete>
|
||||
<mat-spinner
|
||||
matSuffix
|
||||
|
@ -17,7 +17,8 @@ import {
|
||||
MatAutocompleteSelectedEvent
|
||||
} from '@angular/material/autocomplete';
|
||||
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 { takeUntil } from 'rxjs/operators';
|
||||
|
||||
@ -37,6 +38,7 @@ export class ActivitiesFilterComponent implements OnChanges, OnDestroy {
|
||||
@ViewChild('autocomplete') matAutocomplete: MatAutocomplete;
|
||||
@ViewChild('searchInput') searchInput: ElementRef<HTMLInputElement>;
|
||||
|
||||
public filterGroups$: Subject<FilterGroup[]> = new BehaviorSubject([]);
|
||||
public filters$: Subject<Filter[]> = new BehaviorSubject([]);
|
||||
public filters: Observable<Filter[]> = this.filters$.asObservable();
|
||||
public searchControl = new FormControl();
|
||||
@ -50,40 +52,27 @@ export class ActivitiesFilterComponent implements OnChanges, OnDestroy {
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((filterOrSearchTerm: Filter | string) => {
|
||||
if (filterOrSearchTerm) {
|
||||
this.filters$.next(
|
||||
this.allFilters
|
||||
.filter((filter) => {
|
||||
// Filter selected filters
|
||||
return !this.selectedFilters.some((selectedFilter) => {
|
||||
return selectedFilter.id === filter.id;
|
||||
});
|
||||
})
|
||||
.filter((filter) => {
|
||||
if (typeof filterOrSearchTerm === 'string') {
|
||||
return filter.label
|
||||
.toLowerCase()
|
||||
.startsWith(filterOrSearchTerm.toLowerCase());
|
||||
}
|
||||
const searchTerm =
|
||||
typeof filterOrSearchTerm === 'string'
|
||||
? filterOrSearchTerm
|
||||
: filterOrSearchTerm?.label;
|
||||
|
||||
return filter.label
|
||||
.toLowerCase()
|
||||
.startsWith(filterOrSearchTerm?.label?.toLowerCase());
|
||||
})
|
||||
.sort((a, b) => a.label.localeCompare(b.label))
|
||||
);
|
||||
this.filterGroups$.next(this.getGroupedFilters(searchTerm));
|
||||
} else {
|
||||
this.filterGroups$.next(this.getGroupedFilters());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public ngOnChanges(changes: SimpleChanges) {
|
||||
if (changes.allFilters?.currentValue) {
|
||||
this.updateFilter();
|
||||
this.updateFilters();
|
||||
}
|
||||
}
|
||||
|
||||
public onAddFilter({ input, value }: MatChipInputEvent): void {
|
||||
if (value?.trim()) {
|
||||
this.updateFilter();
|
||||
this.updateFilters();
|
||||
}
|
||||
|
||||
// Reset the input value
|
||||
@ -99,12 +88,16 @@ export class ActivitiesFilterComponent implements OnChanges, OnDestroy {
|
||||
return filter.id !== aFilter.id;
|
||||
});
|
||||
|
||||
this.updateFilter();
|
||||
this.updateFilters();
|
||||
}
|
||||
|
||||
public onSelectFilter(event: MatAutocompleteSelectedEvent): void {
|
||||
this.selectedFilters.push(event.option.value);
|
||||
this.updateFilter();
|
||||
this.selectedFilters.push(
|
||||
this.allFilters.find((filter) => {
|
||||
return filter.id === event.option.value;
|
||||
})
|
||||
);
|
||||
this.updateFilters();
|
||||
this.searchInput.nativeElement.value = '';
|
||||
this.searchControl.setValue(null);
|
||||
}
|
||||
@ -114,8 +107,8 @@ export class ActivitiesFilterComponent implements OnChanges, OnDestroy {
|
||||
this.unsubscribeSubject.complete();
|
||||
}
|
||||
|
||||
private updateFilter() {
|
||||
this.filters$.next(
|
||||
private getGroupedFilters(searchTerm?: string) {
|
||||
const filterGroupsMap = groupBy(
|
||||
this.allFilters
|
||||
.filter((filter) => {
|
||||
// Filter selected filters
|
||||
@ -123,9 +116,44 @@ export class ActivitiesFilterComponent implements OnChanges, OnDestroy {
|
||||
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
|
||||
this.valueChanged.emit([...this.selectedFilters]);
|
||||
}
|
||||
|
@ -105,17 +105,17 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy {
|
||||
this.defaultDateFormat = getDateFormatString(this.locale);
|
||||
|
||||
if (this.activities) {
|
||||
this.allFilters = this.getSearchableFieldValues(this.activities).map(
|
||||
(label) => {
|
||||
return { label, id: label, type: 'tag' };
|
||||
}
|
||||
);
|
||||
this.allFilters = this.getSearchableFieldValues(this.activities);
|
||||
|
||||
this.dataSource = new MatTableDataSource(this.activities);
|
||||
this.dataSource.filterPredicate = (data, filter) => {
|
||||
const dataString = this.getFilterableValues(data)
|
||||
.map((currentFilter) => {
|
||||
return currentFilter.label;
|
||||
})
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
|
||||
let contains = true;
|
||||
for (const singleFilter of filter.split(SEARCH_STRING_SEPARATOR)) {
|
||||
contains =
|
||||
@ -190,50 +190,51 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy {
|
||||
|
||||
private getFilterableValues(
|
||||
activity: OrderWithAccount,
|
||||
fieldValues: Set<string> = new Set<string>()
|
||||
): string[] {
|
||||
fieldValues.add(activity.Account?.name);
|
||||
fieldValues.add(activity.Account?.Platform?.name);
|
||||
fieldValues.add(activity.SymbolProfile.currency);
|
||||
fieldValueMap: { [id: string]: Filter } = {}
|
||||
): Filter[] {
|
||||
fieldValueMap[activity.Account?.id] = {
|
||||
id: activity.Account?.id,
|
||||
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)) {
|
||||
fieldValues.add(activity.SymbolProfile.symbol);
|
||||
fieldValueMap[activity.SymbolProfile.symbol] = {
|
||||
id: activity.SymbolProfile.symbol,
|
||||
label: activity.SymbolProfile.symbol,
|
||||
type: 'SYMBOL'
|
||||
};
|
||||
}
|
||||
|
||||
fieldValues.add(activity.type);
|
||||
fieldValues.add(format(activity.date, 'yyyy'));
|
||||
fieldValueMap[activity.type] = {
|
||||
id: activity.type,
|
||||
label: activity.type,
|
||||
type: 'TAG'
|
||||
};
|
||||
|
||||
return [...fieldValues].filter((item) => {
|
||||
return item !== undefined;
|
||||
});
|
||||
fieldValueMap[format(activity.date, 'yyyy')] = {
|
||||
id: format(activity.date, 'yyyy'),
|
||||
label: format(activity.date, 'yyyy'),
|
||||
type: 'TAG'
|
||||
};
|
||||
|
||||
return Object.values(fieldValueMap);
|
||||
}
|
||||
|
||||
private getSearchableFieldValues(activities: OrderWithAccount[]): string[] {
|
||||
const fieldValues = new Set<string>();
|
||||
private getSearchableFieldValues(activities: OrderWithAccount[]): Filter[] {
|
||||
const fieldValueMap: { [id: string]: Filter } = {};
|
||||
|
||||
for (const activity of activities) {
|
||||
this.getFilterableValues(activity, fieldValues);
|
||||
this.getFilterableValues(activity, fieldValueMap);
|
||||
}
|
||||
|
||||
return [...fieldValues]
|
||||
.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;
|
||||
}
|
||||
});
|
||||
return Object.values(fieldValueMap);
|
||||
}
|
||||
|
||||
private getTotalFees() {
|
||||
@ -276,6 +277,7 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy {
|
||||
return filter.label;
|
||||
})
|
||||
.join(SEARCH_STRING_SEPARATOR);
|
||||
|
||||
const lowercaseSearchKeywords = filters.map((filter) => {
|
||||
return filter.label.trim().toLowerCase();
|
||||
});
|
||||
|
Loading…
x
Reference in New Issue
Block a user