From 6057794eb6649f0542e9261418df5e478e5b5109 Mon Sep 17 00:00:00 2001 From: Amandee Ellawala <47607256+amandee27@users.noreply.github.com> Date: Sun, 10 Nov 2024 09:29:43 +0000 Subject: [PATCH] Feature/extend assistant by holding selector (#4031) * Extend assistant by holding selector * Update changelog --- CHANGELOG.md | 6 ++ .../src/app/portfolio/portfolio.controller.ts | 25 +++++ .../src/app/user/update-user-setting.dto.ts | 8 ++ .../app/components/header/header.component.ts | 14 +-- apps/client/src/app/services/data.service.ts | 2 +- .../src/app/services/user/user.service.ts | 14 +++ .../lib/interfaces/user-settings.interface.ts | 2 + .../src/lib/assistant/assistant.component.ts | 94 ++++++++++++++++--- libs/ui/src/lib/assistant/assistant.html | 28 ++++++ 9 files changed, 172 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8131113f3..9a9aacf3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 + +### Changed + +- Extended the assistant by a holding selector + ## 2.122.0 - 2024-11-07 ### Changed diff --git a/apps/api/src/app/portfolio/portfolio.controller.ts b/apps/api/src/app/portfolio/portfolio.controller.ts index 326dda151..f2415dff3 100644 --- a/apps/api/src/app/portfolio/portfolio.controller.ts +++ b/apps/api/src/app/portfolio/portfolio.controller.ts @@ -74,12 +74,15 @@ export class PortfolioController { @Get('details') @UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseInterceptors(RedactValuesInResponseInterceptor) + @UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor) public async getDetails( @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Query('accounts') filterByAccounts?: string, @Query('assetClasses') filterByAssetClasses?: string, + @Query('dataSource') filterByDataSource?: string, @Query('range') dateRange: DateRange = 'max', + @Query('symbol') filterBySymbol?: string, @Query('tags') filterByTags?: string, @Query('withMarkets') withMarketsParam = 'false' ): Promise { @@ -95,6 +98,8 @@ export class PortfolioController { const filters = this.apiService.buildFiltersFromQueryParams({ filterByAccounts, filterByAssetClasses, + filterByDataSource, + filterBySymbol, filterByTags }); @@ -289,17 +294,22 @@ export class PortfolioController { @Get('dividends') @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + @UseInterceptors(TransformDataSourceInRequestInterceptor) public async getDividends( @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Query('accounts') filterByAccounts?: string, @Query('assetClasses') filterByAssetClasses?: string, + @Query('dataSource') filterByDataSource?: string, @Query('groupBy') groupBy?: GroupBy, @Query('range') dateRange: DateRange = 'max', + @Query('symbol') filterBySymbol?: string, @Query('tags') filterByTags?: string ): Promise { const filters = this.apiService.buildFiltersFromQueryParams({ filterByAccounts, filterByAssetClasses, + filterByDataSource, + filterBySymbol, filterByTags }); @@ -356,21 +366,26 @@ export class PortfolioController { @Get('holdings') @UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseInterceptors(RedactValuesInResponseInterceptor) + @UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor) public async getHoldings( @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Query('accounts') filterByAccounts?: string, @Query('assetClasses') filterByAssetClasses?: string, + @Query('dataSource') filterByDataSource?: string, @Query('holdingType') filterByHoldingType?: string, @Query('query') filterBySearchQuery?: string, @Query('range') dateRange: DateRange = 'max', + @Query('symbol') filterBySymbol?: string, @Query('tags') filterByTags?: string ): Promise { const filters = this.apiService.buildFiltersFromQueryParams({ filterByAccounts, filterByAssetClasses, + filterByDataSource, filterByHoldingType, filterBySearchQuery, + filterBySymbol, filterByTags }); @@ -386,17 +401,22 @@ export class PortfolioController { @Get('investments') @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + @UseInterceptors(TransformDataSourceInRequestInterceptor) public async getInvestments( @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Query('accounts') filterByAccounts?: string, @Query('assetClasses') filterByAssetClasses?: string, + @Query('dataSource') filterByDataSource?: string, @Query('groupBy') groupBy?: GroupBy, @Query('range') dateRange: DateRange = 'max', + @Query('symbol') filterBySymbol?: string, @Query('tags') filterByTags?: string ): Promise { const filters = this.apiService.buildFiltersFromQueryParams({ filterByAccounts, filterByAssetClasses, + filterByDataSource, + filterBySymbol, filterByTags }); @@ -451,13 +471,16 @@ export class PortfolioController { @Get('performance') @UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseInterceptors(PerformanceLoggingInterceptor) + @UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor) @Version('2') public async getPerformanceV2( @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Query('accounts') filterByAccounts?: string, @Query('assetClasses') filterByAssetClasses?: string, + @Query('dataSource') filterByDataSource?: string, @Query('range') dateRange: DateRange = 'max', + @Query('symbol') filterBySymbol?: string, @Query('tags') filterByTags?: string, @Query('withExcludedAccounts') withExcludedAccountsParam = 'false' ): Promise { @@ -466,6 +489,8 @@ export class PortfolioController { const filters = this.apiService.buildFiltersFromQueryParams({ filterByAccounts, filterByAssetClasses, + filterByDataSource, + filterBySymbol, filterByTags }); diff --git a/apps/api/src/app/user/update-user-setting.dto.ts b/apps/api/src/app/user/update-user-setting.dto.ts index 13a3a5d2c..b34b6fae2 100644 --- a/apps/api/src/app/user/update-user-setting.dto.ts +++ b/apps/api/src/app/user/update-user-setting.dto.ts @@ -64,6 +64,14 @@ export class UpdateUserSettingDto { @IsOptional() 'filters.assetClasses'?: string[]; + @IsString() + @IsOptional() + 'filters.dataSource'?: string; + + @IsString() + @IsOptional() + 'filters.symbol'?: string; + @IsArray() @IsOptional() 'filters.tags'?: string[]; diff --git a/apps/client/src/app/components/header/header.component.ts b/apps/client/src/app/components/header/header.component.ts index 1739d113f..004fa5f3f 100644 --- a/apps/client/src/app/components/header/header.component.ts +++ b/apps/client/src/app/components/header/header.component.ts @@ -175,17 +175,17 @@ export class HeaderComponent implements OnChanges { const userSetting: UpdateUserSettingDto = {}; for (const filter of filters) { - let filtersType: string; - if (filter.type === 'ACCOUNT') { - filtersType = 'accounts'; + userSetting['filters.accounts'] = filter.id ? [filter.id] : null; } else if (filter.type === 'ASSET_CLASS') { - filtersType = 'assetClasses'; + userSetting['filters.assetClasses'] = filter.id ? [filter.id] : null; + } else if (filter.type === 'DATA_SOURCE') { + userSetting['filters.dataSource'] = filter.id ? filter.id : null; + } else if (filter.type === 'SYMBOL') { + userSetting['filters.symbol'] = filter.id ? filter.id : null; } else if (filter.type === 'TAG') { - filtersType = 'tags'; + userSetting['filters.tags'] = filter.id ? [filter.id] : null; } - - userSetting[`filters.${filtersType}`] = filter.id ? [filter.id] : null; } this.dataService diff --git a/apps/client/src/app/services/data.service.ts b/apps/client/src/app/services/data.service.ts index cde7555b2..dccbb064a 100644 --- a/apps/client/src/app/services/data.service.ts +++ b/apps/client/src/app/services/data.service.ts @@ -532,7 +532,7 @@ export class DataService { }: { filters?: Filter[]; range?: DateRange; - }) { + } = {}) { let params = this.buildFiltersAsQueryParams({ filters }); if (range) { diff --git a/apps/client/src/app/services/user/user.service.ts b/apps/client/src/app/services/user/user.service.ts index 3ecc58c16..aa91a90bd 100644 --- a/apps/client/src/app/services/user/user.service.ts +++ b/apps/client/src/app/services/user/user.service.ts @@ -65,6 +65,20 @@ export class UserService extends ObservableStore { }); } + if (user?.settings['filters.dataSource']) { + filters.push({ + id: user.settings['filters.dataSource'], + type: 'DATA_SOURCE' + }); + } + + if (user?.settings['filters.symbol']) { + filters.push({ + id: user.settings['filters.symbol'], + type: 'SYMBOL' + }); + } + if (user?.settings['filters.tags']) { filters.push({ id: user.settings['filters.tags'][0], diff --git a/libs/common/src/lib/interfaces/user-settings.interface.ts b/libs/common/src/lib/interfaces/user-settings.interface.ts index e9e90e71f..d72be7c7c 100644 --- a/libs/common/src/lib/interfaces/user-settings.interface.ts +++ b/libs/common/src/lib/interfaces/user-settings.interface.ts @@ -14,6 +14,8 @@ export interface UserSettings { dateRange?: DateRange; emergencyFund?: number; 'filters.accounts'?: string[]; + 'filters.dataSource'?: string; + 'filters.symbol'?: string; 'filters.tags'?: string[]; holdingsViewMode?: HoldingsViewMode; isExperimentalFeatures?: boolean; diff --git a/libs/ui/src/lib/assistant/assistant.component.ts b/libs/ui/src/lib/assistant/assistant.component.ts index d73cdb416..3a5e6a2f8 100644 --- a/libs/ui/src/lib/assistant/assistant.component.ts +++ b/libs/ui/src/lib/assistant/assistant.component.ts @@ -1,7 +1,9 @@ import { GfAssetProfileIconComponent } from '@ghostfolio/client/components/asset-profile-icon/asset-profile-icon.component'; +import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module'; import { AdminService } from '@ghostfolio/client/services/admin.service'; import { DataService } from '@ghostfolio/client/services/data.service'; -import { Filter, User } from '@ghostfolio/common/interfaces'; +import { getAssetProfileIdentifier } from '@ghostfolio/common/helper'; +import { Filter, PortfolioPosition, User } from '@ghostfolio/common/interfaces'; import { DateRange } from '@ghostfolio/common/types'; import { translate } from '@ghostfolio/ui/i18n'; @@ -35,7 +37,7 @@ import { MatFormFieldModule } from '@angular/material/form-field'; import { MatMenuTrigger } from '@angular/material/menu'; import { MatSelectModule } from '@angular/material/select'; import { RouterModule } from '@angular/router'; -import { Account, AssetClass } from '@prisma/client'; +import { Account, AssetClass, DataSource } from '@prisma/client'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { EMPTY, Observable, Subject, lastValueFrom } from 'rxjs'; import { @@ -61,6 +63,7 @@ import { FormsModule, GfAssetProfileIconComponent, GfAssistantListItemComponent, + GfSymbolModule, MatButtonModule, MatFormFieldModule, MatSelectModule, @@ -132,8 +135,10 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit { public filterForm = this.formBuilder.group({ account: new FormControl(undefined), assetClass: new FormControl(undefined), + holding: new FormControl(undefined), tag: new FormControl(undefined) }); + public holdings: PortfolioPosition[] = []; public isLoading = false; public isOpen = false; public placeholder = $localize`Find holding...`; @@ -144,7 +149,13 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit { }; public tags: Filter[] = []; - private filterTypes: Filter['type'][] = ['ACCOUNT', 'ASSET_CLASS', 'TAG']; + private filterTypes: Filter['type'][] = [ + 'ACCOUNT', + 'ASSET_CLASS', + 'DATA_SOURCE', + 'SYMBOL', + 'TAG' + ]; private keyManager: FocusKeyManager; private unsubscribeSubject = new Subject(); @@ -156,6 +167,8 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit { ) {} public ngOnInit() { + this.initializeFilterForm(); + this.assetClasses = Object.keys(AssetClass).map((assetClass) => { return { id: assetClass, @@ -263,16 +276,7 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit { this.filterForm.enable({ emitEvent: false }); } - this.filterForm.setValue( - { - account: this.user?.settings?.['filters.accounts']?.[0] ?? null, - assetClass: this.user?.settings?.['filters.assetClasses']?.[0] ?? null, - tag: this.user?.settings?.['filters.tags']?.[0] ?? null - }, - { - emitEvent: false - } - ); + this.initializeFilterForm(); this.tags = this.user?.tags @@ -298,6 +302,19 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit { }); } + public holdingComparisonFunction( + option: PortfolioPosition, + value: PortfolioPosition + ): boolean { + if (value === null) { + return false; + } + + return ( + getAssetProfileIdentifier(option) === getAssetProfileIdentifier(value) + ); + } + public async initialize() { this.isLoading = true; this.keyManager = new FocusKeyManager(this.assistantListItems).withWrap(); @@ -331,6 +348,14 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit { id: this.filterForm.get('assetClass').value, type: 'ASSET_CLASS' }, + { + id: this.filterForm.get('holding').value?.dataSource, + type: 'DATA_SOURCE' + }, + { + id: this.filterForm.get('holding').value?.symbol, + type: 'SYMBOL' + }, { id: this.filterForm.get('tag').value, type: 'TAG' @@ -473,4 +498,47 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit { takeUntil(this.unsubscribeSubject) ); } + + private initializeFilterForm() { + this.dataService + .fetchPortfolioHoldings() + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe(({ holdings }) => { + this.holdings = holdings + .filter(({ assetSubClass }) => { + return !['CASH'].includes(assetSubClass); + }) + .sort((a, b) => { + return a.name?.localeCompare(b.name); + }); + this.setFilterFormValues(); + }); + } + + private setFilterFormValues() { + const dataSource = this.user?.settings?.[ + 'filters.dataSource' + ] as DataSource; + const symbol = this.user?.settings?.['filters.symbol']; + const selectedHolding = this.holdings.find((holding) => { + return ( + getAssetProfileIdentifier({ + dataSource: holding.dataSource, + symbol: holding.symbol + }) === getAssetProfileIdentifier({ dataSource, symbol }) + ); + }); + + this.filterForm.setValue( + { + account: this.user?.settings?.['filters.accounts']?.[0] ?? null, + assetClass: this.user?.settings?.['filters.assetClasses']?.[0] ?? null, + holding: selectedHolding ?? null, + tag: this.user?.settings?.['filters.tags']?.[0] ?? null + }, + { + emitEvent: false + } + ); + } } diff --git a/libs/ui/src/lib/assistant/assistant.html b/libs/ui/src/lib/assistant/assistant.html index 648c791ab..18c2145a3 100644 --- a/libs/ui/src/lib/assistant/assistant.html +++ b/libs/ui/src/lib/assistant/assistant.html @@ -122,6 +122,34 @@ +
+ + Holding + + {{ + filterForm.get('holding')?.value?.name + }} + + @for (holding of holdings; track holding.name) { + +
+ {{ holding.name }} +
+ {{ holding.symbol | gfSymbol }} ยท + {{ holding.currency }} +
+
+ } +
+
+
Tags