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/),
|
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
|
||||||
|
@ -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;
|
||||||
|
@ -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;
|
||||||
|
@ -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'
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
];
|
];
|
||||||
|
@ -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'
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -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;
|
||||||
|
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 {
|
export interface Filter {
|
||||||
id: string;
|
id: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
type: 'account' | 'tag';
|
type: 'ACCOUNT' | 'SYMBOL' | 'TAG';
|
||||||
}
|
}
|
||||||
|
@ -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,
|
||||||
|
@ -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
|
||||||
|
@ -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]);
|
||||||
}
|
}
|
||||||
|
@ -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();
|
||||||
});
|
});
|
||||||
|
Loading…
x
Reference in New Issue
Block a user