diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f2d4c10..f4e37b18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,9 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Added support for filtering by accounts on the allocations page - Added support for private equity - Extended the form to set the asset and asset sub class for (wealth) items +### Changed + +- Refactored the filtering (activities table and allocations page) + ### Fixed - Fixed the tooltip update in the portfolio proportion chart component diff --git a/apps/api/src/app/order/order.service.ts b/apps/api/src/app/order/order.service.ts index 762b414b..ed5bbe0c 100644 --- a/apps/api/src/app/order/order.service.ts +++ b/apps/api/src/app/order/order.service.ts @@ -4,6 +4,7 @@ import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.se import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service'; +import { Filter } from '@ghostfolio/common/interfaces'; import { OrderWithAccount } from '@ghostfolio/common/types'; import { Injectable } from '@nestjs/common'; import { @@ -16,6 +17,7 @@ import { } from '@prisma/client'; import Big from 'big.js'; import { endOfToday, isAfter } from 'date-fns'; +import { groupBy } from 'lodash'; import { v4 as uuidv4 } from 'uuid'; import { Activity } from './interfaces/activities.interface'; @@ -166,31 +168,44 @@ export class OrderService { } public async getOrders({ + filters, includeDrafts = false, - tags, types, userCurrency, userId }: { + filters?: Filter[]; includeDrafts?: boolean; - tags?: string[]; types?: TypeOfOrder[]; userCurrency: string; userId: string; }): Promise { const where: Prisma.OrderWhereInput = { userId }; + const { account: filtersByAccount, tag: filtersByTag } = groupBy( + filters, + (filter) => { + return filter.type; + } + ); + + if (filtersByAccount?.length > 0) { + where.accountId = { + in: filtersByAccount.map(({ id }) => { + return id; + }) + }; + } + if (includeDrafts === false) { where.isDraft = false; } - if (tags?.length > 0) { + if (filtersByTag?.length > 0) { where.tags = { some: { - OR: tags.map((tag) => { - return { - name: tag - }; + OR: filtersByTag.map(({ id }) => { + return { id }; }) } }; diff --git a/apps/api/src/app/portfolio/portfolio.controller.ts b/apps/api/src/app/portfolio/portfolio.controller.ts index 9f9b20c5..37252b8f 100644 --- a/apps/api/src/app/portfolio/portfolio.controller.ts +++ b/apps/api/src/app/portfolio/portfolio.controller.ts @@ -11,6 +11,7 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate- import { baseCurrency } from '@ghostfolio/common/config'; import { parseDate } from '@ghostfolio/common/helper'; import { + Filter, PortfolioChart, PortfolioDetails, PortfolioInvestments, @@ -19,7 +20,7 @@ import { PortfolioReport, PortfolioSummary } from '@ghostfolio/common/interfaces'; -import type { RequestWithUser } from '@ghostfolio/common/types'; +import type { DateRange, RequestWithUser } from '@ghostfolio/common/types'; import { Controller, Get, @@ -105,17 +106,36 @@ export class PortfolioController { @UseInterceptors(TransformDataSourceInResponseInterceptor) public async getDetails( @Headers('impersonation-id') impersonationId: string, - @Query('range') range, - @Query('tags') tags?: string + @Query('accounts') filterByAccounts?: string, + @Query('range') range?: DateRange, + @Query('tags') filterByTags?: string ): Promise { let hasError = false; + const accountIds = filterByAccounts?.split(',') ?? []; + const tagIds = filterByTags?.split(',') ?? []; + + const filters: Filter[] = [ + ...accountIds.map((accountId) => { + return { + id: accountId, + type: 'account' + }; + }), + ...tagIds.map((tagId) => { + return { + id: tagId, + type: 'tag' + }; + }) + ]; + const { accounts, holdings, hasErrors } = await this.portfolioService.getDetails( impersonationId, this.request.user.id, range, - tags?.split(',') + filters ); if (hasErrors || hasNotDefinedValuesInObject(holdings)) { @@ -163,7 +183,7 @@ export class PortfolioController { return { hasError, - accounts: tags ? {} : accounts, + accounts: filters ? {} : accounts, holdings: isBasicUser ? {} : holdings }; } diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index ecc0ea20..795f516e 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -29,6 +29,7 @@ import { import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper'; import { Accounts, + Filter, PortfolioDetails, PortfolioPerformanceResponse, PortfolioReport, @@ -309,7 +310,7 @@ export class PortfolioService { aImpersonationId: string, aUserId: string, aDateRange: DateRange = 'max', - tags?: string[] + aFilters?: Filter[] ): Promise { const userId = await this.getUserId(aImpersonationId, aUserId); const user = await this.userService.user({ id: userId }); @@ -324,8 +325,8 @@ export class PortfolioService { const { orders, portfolioOrders, transactionPoints } = await this.getTransactionPoints({ - tags, - userId + userId, + filters: aFilters }); const portfolioCalculator = new PortfolioCalculator({ @@ -448,7 +449,7 @@ export class PortfolioService { value: totalValue }); - if (tags === undefined) { + if (aFilters === undefined) { for (const symbol of Object.keys(cashPositions)) { holdings[symbol] = cashPositions[symbol]; } @@ -1195,12 +1196,12 @@ export class PortfolioService { } private async getTransactionPoints({ + filters, includeDrafts = false, - tags, userId }: { + filters?: Filter[]; includeDrafts?: boolean; - tags?: string[]; userId: string; }): Promise<{ transactionPoints: TransactionPoint[]; @@ -1210,8 +1211,8 @@ export class PortfolioService { const userCurrency = this.request.user?.Settings?.currency ?? baseCurrency; const orders = await this.orderService.getOrders({ + filters, includeDrafts, - tags, userCurrency, userId, types: ['BUY', 'SELL'] diff --git a/apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts b/apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts index b8842cea..1c20dc51 100644 --- a/apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts +++ b/apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts @@ -8,6 +8,7 @@ import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UNKNOWN_KEY } from '@ghostfolio/common/config'; import { prettifySymbol } from '@ghostfolio/common/helper'; import { + Filter, PortfolioDetails, PortfolioPosition, UniqueAsset, @@ -32,6 +33,7 @@ export class AllocationsPageComponent implements OnDestroy, OnInit { value: number; }; }; + public allFilters: Filter[]; public continents: { [code: string]: { name: string; value: number }; }; @@ -39,7 +41,7 @@ export class AllocationsPageComponent implements OnDestroy, OnInit { [code: string]: { name: string; value: number }; }; public deviceType: string; - public filters$ = new Subject(); + public filters$ = new Subject(); public hasImpersonationId: boolean; public isLoading = false; public markets: { @@ -76,7 +78,6 @@ export class AllocationsPageComponent implements OnDestroy, OnInit { value: number; }; }; - public tags: string[] = []; public user: User; @@ -127,10 +128,10 @@ export class AllocationsPageComponent implements OnDestroy, OnInit { this.filters$ .pipe( distinctUntilChanged(), - switchMap((tags) => { + switchMap((filters) => { this.isLoading = true; - return this.dataService.fetchPortfolioDetails({ tags }); + return this.dataService.fetchPortfolioDetails({ filters }); }), takeUntil(this.unsubscribeSubject) ) @@ -150,10 +151,26 @@ export class AllocationsPageComponent implements OnDestroy, OnInit { if (state?.user) { this.user = state.user; - this.tags = this.user.tags.map((tag) => { - return tag.name; + const accountFilters: Filter[] = this.user.accounts.map( + ({ id, name }) => { + return { + id: id, + label: name, + type: 'account' + }; + } + ); + + const tagFilters: Filter[] = this.user.tags.map(({ id, name }) => { + return { + id, + label: name, + type: 'tag' + }; }); + this.allFilters = [...accountFilters, ...tagFilters]; + this.changeDetectorRef.markForCheck(); } }); diff --git a/apps/client/src/app/pages/portfolio/allocations/allocations-page.html b/apps/client/src/app/pages/portfolio/allocations/allocations-page.html index 74b9330e..07418a4a 100644 --- a/apps/client/src/app/pages/portfolio/allocations/allocations-page.html +++ b/apps/client/src/app/pages/portfolio/allocations/allocations-page.html @@ -3,9 +3,8 @@

Allocations

diff --git a/apps/client/src/app/services/data.service.ts b/apps/client/src/app/services/data.service.ts index c5998282..7679c154 100644 --- a/apps/client/src/app/services/data.service.ts +++ b/apps/client/src/app/services/data.service.ts @@ -19,6 +19,7 @@ import { AdminData, AdminMarketData, Export, + Filter, InfoItem, PortfolioChart, PortfolioDetails, @@ -33,7 +34,7 @@ import { permissions } from '@ghostfolio/common/permissions'; import { DateRange } from '@ghostfolio/common/types'; import { DataSource, Order as OrderModel } from '@prisma/client'; import { parseISO } from 'date-fns'; -import { cloneDeep } from 'lodash'; +import { cloneDeep, groupBy } from 'lodash'; import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; @@ -182,11 +183,38 @@ export class DataService { ); } - public fetchPortfolioDetails({ tags }: { tags?: string[] }) { + public fetchPortfolioDetails({ filters }: { filters?: Filter[] }) { let params = new HttpParams(); - if (tags?.length > 0) { - params = params.append('tags', tags.join(',')); + if (filters?.length > 0) { + const { account: filtersByAccount, tag: filtersByTag } = groupBy( + filters, + (filter) => { + return filter.type; + } + ); + + if (filtersByAccount) { + params = params.append( + 'accounts', + filtersByAccount + .map(({ id }) => { + return id; + }) + .join(',') + ); + } + + if (filtersByTag) { + params = params.append( + 'tags', + filtersByTag + .map(({ id }) => { + return id; + }) + .join(',') + ); + } } return this.http.get('/api/v1/portfolio/details', { diff --git a/libs/common/src/lib/interfaces/filter.interface.ts b/libs/common/src/lib/interfaces/filter.interface.ts new file mode 100644 index 00000000..e6d5bb10 --- /dev/null +++ b/libs/common/src/lib/interfaces/filter.interface.ts @@ -0,0 +1,5 @@ +export interface Filter { + id: string; + label?: string; + type: 'account' | 'tag'; +} diff --git a/libs/common/src/lib/interfaces/index.ts b/libs/common/src/lib/interfaces/index.ts index d2ad5074..0b20b8f2 100644 --- a/libs/common/src/lib/interfaces/index.ts +++ b/libs/common/src/lib/interfaces/index.ts @@ -8,6 +8,7 @@ import { } from './admin-market-data.interface'; import { Coupon } from './coupon.interface'; import { Export } from './export.interface'; +import { Filter } from './filter.interface'; import { InfoItem } from './info-item.interface'; import { PortfolioChart } from './portfolio-chart.interface'; import { PortfolioDetails } from './portfolio-details.interface'; @@ -38,6 +39,7 @@ export { AdminMarketDataItem, Coupon, Export, + Filter, InfoItem, PortfolioChart, PortfolioDetails, diff --git a/libs/ui/src/lib/activities-filter/activities-filter.component.html b/libs/ui/src/lib/activities-filter/activities-filter.component.html index 98c40d4b..e0eb5da8 100644 --- a/libs/ui/src/lib/activities-filter/activities-filter.component.html +++ b/libs/ui/src/lib/activities-filter/activities-filter.component.html @@ -2,13 +2,13 @@ - {{ searchKeyword | gfSymbol }} + {{ filter.label | gfSymbol }} - {{ filter | gfSymbol }} + {{ filter.label | gfSymbol }} (); + @Output() valueChanged = new EventEmitter(); @ViewChild('autocomplete') matAutocomplete: MatAutocomplete; @ViewChild('searchInput') searchInput: ElementRef; - public filters$: Subject = new BehaviorSubject([]); - public filters: Observable = this.filters$.asObservable(); + public filters$: Subject = new BehaviorSubject([]); + public filters: Observable = this.filters$.asObservable(); public searchControl = new FormControl(); - public searchKeywords: string[] = []; + public selectedFilters: Filter[] = []; public separatorKeysCodes: number[] = [ENTER, COMMA]; private unsubscribeSubject = new Subject(); @@ -47,16 +48,23 @@ export class ActivitiesFilterComponent implements OnChanges, OnDestroy { public constructor() { this.searchControl.valueChanges .pipe(takeUntil(this.unsubscribeSubject)) - .subscribe((keyword) => { - if (keyword) { - const filterValue = keyword.toLowerCase(); + .subscribe((currentFilter: string) => { + if (currentFilter) { this.filters$.next( - this.allFilters.filter( - (filter) => filter.toLowerCase().indexOf(filterValue) === 0 - ) + this.allFilters + .filter((filter) => { + // Filter selected filters + return !this.selectedFilters.some((selectedFilter) => { + return selectedFilter.id === filter.id; + }); + }) + .filter((filter) => { + return filter.label + .toLowerCase() + .startsWith(currentFilter?.toLowerCase()); + }) + .sort((a, b) => a.label.localeCompare(b.label)) ); - } else { - this.filters$.next(this.allFilters); } }); } @@ -67,9 +75,8 @@ export class ActivitiesFilterComponent implements OnChanges, OnDestroy { } } - public addKeyword({ input, value }: MatChipInputEvent): void { + public onAddFilter({ input, value }: MatChipInputEvent): void { if (value?.trim()) { - this.searchKeywords.push(value.trim()); this.updateFilter(); } @@ -81,31 +88,39 @@ export class ActivitiesFilterComponent implements OnChanges, OnDestroy { this.searchControl.setValue(null); } - public keywordSelected(event: MatAutocompleteSelectedEvent): void { - this.searchKeywords.push(event.option.viewValue); + public onRemoveFilter(aFilter: Filter): void { + this.selectedFilters = this.selectedFilters.filter((filter) => { + return filter.id !== aFilter.id; + }); + + this.updateFilter(); + } + + public onSelectFilter(event: MatAutocompleteSelectedEvent): void { + this.selectedFilters.push(event.option.value); this.updateFilter(); this.searchInput.nativeElement.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 ngOnDestroy() { this.unsubscribeSubject.next(); this.unsubscribeSubject.complete(); } private updateFilter() { - this.filters$.next(this.allFilters); + this.filters$.next( + this.allFilters + .filter((filter) => { + // Filter selected filters + return !this.selectedFilters.some((selectedFilter) => { + return selectedFilter.id === filter.id; + }); + }) + .sort((a, b) => a.label.localeCompare(b.label)) + ); // Emit an array with a new reference - this.valueChanged.emit([...this.searchKeywords]); + this.valueChanged.emit([...this.selectedFilters]); } } diff --git a/libs/ui/src/lib/activities-table/activities-table.component.html b/libs/ui/src/lib/activities-table/activities-table.component.html index a047053d..60948d60 100644 --- a/libs/ui/src/lib/activities-table/activities-table.component.html +++ b/libs/ui/src/lib/activities-table/activities-table.component.html @@ -1,8 +1,9 @@
diff --git a/libs/ui/src/lib/activities-table/activities-table.component.ts b/libs/ui/src/lib/activities-table/activities-table.component.ts index 03e8cf04..f2fd54bb 100644 --- a/libs/ui/src/lib/activities-table/activities-table.component.ts +++ b/libs/ui/src/lib/activities-table/activities-table.component.ts @@ -14,13 +14,13 @@ import { MatTableDataSource } from '@angular/material/table'; import { Router } from '@angular/router'; import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; import { getDateFormatString } from '@ghostfolio/common/helper'; -import { UniqueAsset } from '@ghostfolio/common/interfaces'; +import { Filter, UniqueAsset } from '@ghostfolio/common/interfaces'; import { OrderWithAccount } from '@ghostfolio/common/types'; import Big from 'big.js'; import { isUUID } from 'class-validator'; import { endOfToday, format, isAfter } from 'date-fns'; import { isNumber } from 'lodash'; -import { Subject, Subscription } from 'rxjs'; +import { distinctUntilChanged, Subject, Subscription, takeUntil } from 'rxjs'; const SEARCH_PLACEHOLDER = 'Search for account, currency, symbol or type...'; const SEARCH_STRING_SEPARATOR = ','; @@ -53,11 +53,12 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy { @ViewChild(MatSort) sort: MatSort; - public allFilters: string[]; + public allFilters: Filter[]; public dataSource: MatTableDataSource = new MatTableDataSource(); public defaultDateFormat: string; public displayedColumns = []; public endOfToday = endOfToday(); + public filters$ = new Subject(); public hasDrafts = false; public isAfter = isAfter; public isLoading = true; @@ -71,7 +72,13 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy { private unsubscribeSubject = new Subject(); - public constructor(private router: Router) {} + public constructor(private router: Router) { + this.filters$ + .pipe(distinctUntilChanged(), takeUntil(this.unsubscribeSubject)) + .subscribe((filters) => { + this.updateFilters(filters); + }); + } public ngOnChanges() { this.displayedColumns = [ @@ -95,11 +102,15 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy { }); } - this.isLoading = true; - this.defaultDateFormat = getDateFormatString(this.locale); if (this.activities) { + this.allFilters = this.getSearchableFieldValues(this.activities).map( + (label) => { + return { label, id: label, type: 'tag' }; + } + ); + this.dataSource = new MatTableDataSource(this.activities); this.dataSource.filterPredicate = (data, filter) => { const dataString = this.getFilterableValues(data) @@ -113,8 +124,8 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy { return contains; }; this.dataSource.sort = this.sort; - this.updateFilter(); - this.isLoading = false; + + this.updateFilters(); } } @@ -172,30 +183,6 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy { this.activityToUpdate.emit(aActivity); } - public updateFilter(filters: string[] = []) { - this.dataSource.filter = filters.join(SEARCH_STRING_SEPARATOR); - const lowercaseSearchKeywords = filters.map((keyword) => - keyword.trim().toLowerCase() - ); - - this.placeholder = - lowercaseSearchKeywords.length <= 0 ? SEARCH_PLACEHOLDER : ''; - - this.searchKeywords = filters; - - this.allFilters = this.getSearchableFieldValues(this.activities).filter( - (item) => { - return !lowercaseSearchKeywords.includes(item.trim().toLowerCase()); - } - ); - - this.hasDrafts = this.dataSource.data.some((activity) => { - return activity.isDraft === true; - }); - this.totalFees = this.getTotalFees(); - this.totalValue = this.getTotalValue(); - } - public ngOnDestroy() { this.unsubscribeSubject.next(); this.unsubscribeSubject.complete(); @@ -280,4 +267,32 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy { return totalValue.toNumber(); } + + private updateFilters(filters: Filter[] = []) { + this.isLoading = true; + + this.dataSource.filter = filters + .map((filter) => { + return filter.label; + }) + .join(SEARCH_STRING_SEPARATOR); + const lowercaseSearchKeywords = filters.map((filter) => { + return filter.label.trim().toLowerCase(); + }); + + this.placeholder = + lowercaseSearchKeywords.length <= 0 ? SEARCH_PLACEHOLDER : ''; + + this.searchKeywords = filters.map((filter) => { + return filter.label; + }); + + this.hasDrafts = this.dataSource.filteredData.some((activity) => { + return activity.isDraft === true; + }); + this.totalFees = this.getTotalFees(); + this.totalValue = this.getTotalValue(); + + this.isLoading = false; + } }