From c671ea4022fe6e3d9ec12861c61ee98213df19eb Mon Sep 17 00:00:00 2001 From: Kenrick Tandrian <60643640+KenTandrian@users.noreply.github.com> Date: Mon, 28 Apr 2025 22:17:35 +0700 Subject: [PATCH] Feature/add frontend for watchlist (#4604) * Add frontend for watchlist * Update changelog --------- Co-authored-by: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> --- CHANGELOG.md | 6 + .../watchlist/watchlist.controller.ts | 26 ++-- ...reate-watchlist-item-dialog.component.scss | 3 + .../create-watchlist-item-dialog.component.ts | 92 +++++++++++ .../create-watchlist-item-dialog.html | 25 +++ .../interfaces/interfaces.ts | 4 + .../home-watchlist.component.ts | 145 ++++++++++++++++++ .../home-watchlist/home-watchlist.html | 33 ++++ .../home-watchlist/home-watchlist.scss | 3 + .../pages/home/home-page-routing.module.ts | 6 + .../src/app/pages/home/home-page.component.ts | 6 + .../src/app/pages/home/home-page.module.ts | 2 + apps/client/src/app/services/data.service.ts | 12 +- libs/common/src/lib/interfaces/index.ts | 2 + .../responses/watchlist-response.interface.ts | 5 + .../lib/benchmark/benchmark.component.html | 38 +++-- .../src/lib/benchmark/benchmark.component.ts | 2 + 17 files changed, 382 insertions(+), 28 deletions(-) create mode 100644 apps/client/src/app/components/home-watchlist/create-watchlist-item-dialog/create-watchlist-item-dialog.component.scss create mode 100644 apps/client/src/app/components/home-watchlist/create-watchlist-item-dialog/create-watchlist-item-dialog.component.ts create mode 100644 apps/client/src/app/components/home-watchlist/create-watchlist-item-dialog/create-watchlist-item-dialog.html create mode 100644 apps/client/src/app/components/home-watchlist/create-watchlist-item-dialog/interfaces/interfaces.ts create mode 100644 apps/client/src/app/components/home-watchlist/home-watchlist.component.ts create mode 100644 apps/client/src/app/components/home-watchlist/home-watchlist.html create mode 100644 apps/client/src/app/components/home-watchlist/home-watchlist.scss create mode 100644 libs/common/src/lib/interfaces/responses/watchlist-response.interface.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 918876f6c..2e9285773 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 + +### Added + +- Introduced a watchlist to follow assets (experimental) + ## 2.156.0 - 2025-04-27 ### Changed diff --git a/apps/api/src/app/endpoints/watchlist/watchlist.controller.ts b/apps/api/src/app/endpoints/watchlist/watchlist.controller.ts index 0d25172c8..c9e41d5d3 100644 --- a/apps/api/src/app/endpoints/watchlist/watchlist.controller.ts +++ b/apps/api/src/app/endpoints/watchlist/watchlist.controller.ts @@ -2,7 +2,7 @@ import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorat import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor'; -import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces'; +import { WatchlistResponse } from '@ghostfolio/common/interfaces'; import { permissions } from '@ghostfolio/common/permissions'; import { RequestWithUser } from '@ghostfolio/common/types'; @@ -53,13 +53,13 @@ export class WatchlistController { @Param('dataSource') dataSource: DataSource, @Param('symbol') symbol: string ) { - const watchlistItem = await this.watchlistService - .getWatchlistItems(this.request.user.id) - .then((items) => { - return items.find((item) => { - return item.dataSource === dataSource && item.symbol === symbol; - }); - }); + const watchlistItems = await this.watchlistService.getWatchlistItems( + this.request.user.id + ); + + const watchlistItem = watchlistItems.find((item) => { + return item.dataSource === dataSource && item.symbol === symbol; + }); if (!watchlistItem) { throw new HttpException( @@ -79,7 +79,13 @@ export class WatchlistController { @HasPermission(permissions.readWatchlist) @UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseInterceptors(TransformDataSourceInResponseInterceptor) - public async getWatchlistItems(): Promise { - return this.watchlistService.getWatchlistItems(this.request.user.id); + public async getWatchlistItems(): Promise { + const watchlist = await this.watchlistService.getWatchlistItems( + this.request.user.id + ); + + return { + watchlist + }; } } diff --git a/apps/client/src/app/components/home-watchlist/create-watchlist-item-dialog/create-watchlist-item-dialog.component.scss b/apps/client/src/app/components/home-watchlist/create-watchlist-item-dialog/create-watchlist-item-dialog.component.scss new file mode 100644 index 000000000..5d4e87f30 --- /dev/null +++ b/apps/client/src/app/components/home-watchlist/create-watchlist-item-dialog/create-watchlist-item-dialog.component.scss @@ -0,0 +1,3 @@ +:host { + display: block; +} diff --git a/apps/client/src/app/components/home-watchlist/create-watchlist-item-dialog/create-watchlist-item-dialog.component.ts b/apps/client/src/app/components/home-watchlist/create-watchlist-item-dialog/create-watchlist-item-dialog.component.ts new file mode 100644 index 000000000..722f680c3 --- /dev/null +++ b/apps/client/src/app/components/home-watchlist/create-watchlist-item-dialog/create-watchlist-item-dialog.component.ts @@ -0,0 +1,92 @@ +import { GfSymbolAutocompleteComponent } from '@ghostfolio/ui/symbol-autocomplete'; + +import { CommonModule } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + OnDestroy, + OnInit +} from '@angular/core'; +import { + AbstractControl, + FormBuilder, + FormControl, + FormGroup, + FormsModule, + ReactiveFormsModule, + ValidationErrors, + Validators +} from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MatDialogModule, MatDialogRef } from '@angular/material/dialog'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { Subject } from 'rxjs'; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + host: { class: 'h-100' }, + imports: [ + CommonModule, + FormsModule, + GfSymbolAutocompleteComponent, + MatButtonModule, + MatDialogModule, + MatFormFieldModule, + ReactiveFormsModule + ], + selector: 'gf-create-watchlist-item-dialog', + styleUrls: ['./create-watchlist-item-dialog.component.scss'], + templateUrl: 'create-watchlist-item-dialog.html' +}) +export class CreateWatchlistItemDialogComponent implements OnInit, OnDestroy { + public createWatchlistItemForm: FormGroup; + + private unsubscribeSubject = new Subject(); + + public constructor( + public readonly dialogRef: MatDialogRef, + public readonly formBuilder: FormBuilder + ) {} + + public ngOnInit() { + this.createWatchlistItemForm = this.formBuilder.group( + { + searchSymbol: new FormControl(null, [Validators.required]) + }, + { + validators: this.validator + } + ); + } + + public onCancel() { + this.dialogRef.close(); + } + + public onSubmit() { + this.dialogRef.close({ + dataSource: + this.createWatchlistItemForm.get('searchSymbol').value.dataSource, + symbol: this.createWatchlistItemForm.get('searchSymbol').value.symbol + }); + } + + public ngOnDestroy() { + this.unsubscribeSubject.next(); + this.unsubscribeSubject.complete(); + } + + private validator(control: AbstractControl): ValidationErrors { + const searchSymbolControl = control.get('searchSymbol'); + + if ( + searchSymbolControl.valid && + searchSymbolControl.value.dataSource && + searchSymbolControl.value.symbol + ) { + return { incomplete: false }; + } + + return { incomplete: true }; + } +} diff --git a/apps/client/src/app/components/home-watchlist/create-watchlist-item-dialog/create-watchlist-item-dialog.html b/apps/client/src/app/components/home-watchlist/create-watchlist-item-dialog/create-watchlist-item-dialog.html new file mode 100644 index 000000000..dd59a9309 --- /dev/null +++ b/apps/client/src/app/components/home-watchlist/create-watchlist-item-dialog/create-watchlist-item-dialog.html @@ -0,0 +1,25 @@ +
+

Add asset to watchlist

+
+ + Name, symbol or ISIN + + +
+
+ + +
+
diff --git a/apps/client/src/app/components/home-watchlist/create-watchlist-item-dialog/interfaces/interfaces.ts b/apps/client/src/app/components/home-watchlist/create-watchlist-item-dialog/interfaces/interfaces.ts new file mode 100644 index 000000000..c0f74d022 --- /dev/null +++ b/apps/client/src/app/components/home-watchlist/create-watchlist-item-dialog/interfaces/interfaces.ts @@ -0,0 +1,4 @@ +export interface CreateWatchlistItemDialogParams { + deviceType: string; + locale: string; +} diff --git a/apps/client/src/app/components/home-watchlist/home-watchlist.component.ts b/apps/client/src/app/components/home-watchlist/home-watchlist.component.ts new file mode 100644 index 000000000..0198ab27a --- /dev/null +++ b/apps/client/src/app/components/home-watchlist/home-watchlist.component.ts @@ -0,0 +1,145 @@ +import { DataService } from '@ghostfolio/client/services/data.service'; +import { UserService } from '@ghostfolio/client/services/user/user.service'; +import { Benchmark, User } from '@ghostfolio/common/interfaces'; +import { hasPermission, permissions } from '@ghostfolio/common/permissions'; +import { GfBenchmarkComponent } from '@ghostfolio/ui/benchmark'; +import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator'; + +import { CommonModule } from '@angular/common'; +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + CUSTOM_ELEMENTS_SCHEMA, + OnDestroy, + OnInit +} from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatDialog } from '@angular/material/dialog'; +import { ActivatedRoute, Router, RouterModule } from '@angular/router'; +import { DeviceDetectorService } from 'ngx-device-detector'; +import { Subject } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; + +import { CreateWatchlistItemDialogComponent } from './create-watchlist-item-dialog/create-watchlist-item-dialog.component'; +import { CreateWatchlistItemDialogParams } from './create-watchlist-item-dialog/interfaces/interfaces'; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + CommonModule, + GfBenchmarkComponent, + GfPremiumIndicatorComponent, + MatButtonModule, + RouterModule + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + selector: 'gf-home-watchlist', + styleUrls: ['./home-watchlist.scss'], + templateUrl: './home-watchlist.html' +}) +export class HomeWatchlistComponent implements OnDestroy, OnInit { + public deviceType: string; + public hasPermissionToCreateWatchlistItem: boolean; + public user: User; + public watchlist: Benchmark[]; + + private unsubscribeSubject = new Subject(); + + public constructor( + private changeDetectorRef: ChangeDetectorRef, + private dataService: DataService, + private deviceService: DeviceDetectorService, + private dialog: MatDialog, + private route: ActivatedRoute, + private router: Router, + private userService: UserService + ) { + this.deviceType = this.deviceService.getDeviceInfo().deviceType; + + this.route.queryParams + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe((params) => { + if (params['createWatchlistItemDialog']) { + this.openCreateWatchlistItemDialog(); + } + }); + + this.userService.stateChanged + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe((state) => { + if (state?.user) { + this.user = state.user; + + this.hasPermissionToCreateWatchlistItem = hasPermission( + this.user.permissions, + permissions.createWatchlistItem + ); + + this.changeDetectorRef.markForCheck(); + } + }); + } + + public ngOnInit() { + this.loadWatchlistData(); + } + + public ngOnDestroy() { + this.unsubscribeSubject.next(); + this.unsubscribeSubject.complete(); + } + + private loadWatchlistData() { + this.dataService + .fetchWatchlist() + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe(({ watchlist }) => { + this.watchlist = watchlist.map(({ dataSource, symbol }) => ({ + dataSource, + symbol, + marketCondition: null, + name: symbol, + performances: null, + trend50d: 'UNKNOWN', + trend200d: 'UNKNOWN' + })); + + this.changeDetectorRef.markForCheck(); + }); + } + + private openCreateWatchlistItemDialog() { + this.userService + .get() + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe((user) => { + this.user = user; + + const dialogRef = this.dialog.open(CreateWatchlistItemDialogComponent, { + autoFocus: false, + data: { + deviceType: this.deviceType, + locale: this.user?.settings?.locale + } as CreateWatchlistItemDialogParams, + width: this.deviceType === 'mobile' ? '100vw' : '50rem' + }); + + dialogRef + .afterClosed() + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe(({ dataSource, symbol } = {}) => { + if (dataSource && symbol) { + this.dataService + .postWatchlistItem({ dataSource, symbol }) + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe({ + next: () => this.loadWatchlistData() + }); + } + + this.router.navigate(['.'], { relativeTo: this.route }); + }); + }); + } +} diff --git a/apps/client/src/app/components/home-watchlist/home-watchlist.html b/apps/client/src/app/components/home-watchlist/home-watchlist.html new file mode 100644 index 000000000..0a2e37279 --- /dev/null +++ b/apps/client/src/app/components/home-watchlist/home-watchlist.html @@ -0,0 +1,33 @@ +
+

+ + Watchlist + @if (user?.subscription?.type === 'Basic') { + + } + +

+
+
+ +
+
+
+@if (hasPermissionToCreateWatchlistItem) { +
+ + + +
+} diff --git a/apps/client/src/app/components/home-watchlist/home-watchlist.scss b/apps/client/src/app/components/home-watchlist/home-watchlist.scss new file mode 100644 index 000000000..5d4e87f30 --- /dev/null +++ b/apps/client/src/app/components/home-watchlist/home-watchlist.scss @@ -0,0 +1,3 @@ +:host { + display: block; +} diff --git a/apps/client/src/app/pages/home/home-page-routing.module.ts b/apps/client/src/app/pages/home/home-page-routing.module.ts index f50b55192..9a915f0b3 100644 --- a/apps/client/src/app/pages/home/home-page-routing.module.ts +++ b/apps/client/src/app/pages/home/home-page-routing.module.ts @@ -2,6 +2,7 @@ import { HomeHoldingsComponent } from '@ghostfolio/client/components/home-holdin import { HomeMarketComponent } from '@ghostfolio/client/components/home-market/home-market.component'; import { HomeOverviewComponent } from '@ghostfolio/client/components/home-overview/home-overview.component'; import { HomeSummaryComponent } from '@ghostfolio/client/components/home-summary/home-summary.component'; +import { HomeWatchlistComponent } from '@ghostfolio/client/components/home-watchlist/home-watchlist.component'; import { AuthGuard } from '@ghostfolio/client/core/auth.guard'; import { NgModule } from '@angular/core'; @@ -36,6 +37,11 @@ const routes: Routes = [ path: 'market', component: HomeMarketComponent, title: $localize`Markets` + }, + { + path: 'watchlist', + component: HomeWatchlistComponent, + title: $localize`Watchlist` } ], component: HomePageComponent, diff --git a/apps/client/src/app/pages/home/home-page.component.ts b/apps/client/src/app/pages/home/home-page.component.ts index e307884f8..70e0c34fe 100644 --- a/apps/client/src/app/pages/home/home-page.component.ts +++ b/apps/client/src/app/pages/home/home-page.component.ts @@ -52,6 +52,12 @@ export class HomePageComponent implements OnDestroy, OnInit { iconName: 'newspaper-outline', label: $localize`Markets`, path: ['/home', 'market'] + }, + { + iconName: 'star-outline', + label: $localize`Watchlist`, + path: ['/home', 'watchlist'], + showCondition: this.user?.settings?.isExperimentalFeatures } ]; this.user = state.user; diff --git a/apps/client/src/app/pages/home/home-page.module.ts b/apps/client/src/app/pages/home/home-page.module.ts index 045cfa8c0..32f031e4e 100644 --- a/apps/client/src/app/pages/home/home-page.module.ts +++ b/apps/client/src/app/pages/home/home-page.module.ts @@ -2,6 +2,7 @@ import { GfHomeHoldingsModule } from '@ghostfolio/client/components/home-holding import { GfHomeMarketModule } from '@ghostfolio/client/components/home-market/home-market.module'; import { GfHomeOverviewModule } from '@ghostfolio/client/components/home-overview/home-overview.module'; import { GfHomeSummaryModule } from '@ghostfolio/client/components/home-summary/home-summary.module'; +import { HomeWatchlistComponent } from '@ghostfolio/client/components/home-watchlist/home-watchlist.component'; import { CommonModule } from '@angular/common'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; @@ -20,6 +21,7 @@ import { HomePageComponent } from './home-page.component'; GfHomeOverviewModule, GfHomeSummaryModule, HomePageRoutingModule, + HomeWatchlistComponent, MatTabsModule, RouterModule ], diff --git a/apps/client/src/app/services/data.service.ts b/apps/client/src/app/services/data.service.ts index 55d76d667..526c61972 100644 --- a/apps/client/src/app/services/data.service.ts +++ b/apps/client/src/app/services/data.service.ts @@ -6,6 +6,7 @@ import { UpdateAccountDto } from '@ghostfolio/api/app/account/update-account.dto import { UpdateBulkMarketDataDto } from '@ghostfolio/api/app/admin/update-bulk-market-data.dto'; import { CreateTagDto } from '@ghostfolio/api/app/endpoints/tags/create-tag.dto'; import { UpdateTagDto } from '@ghostfolio/api/app/endpoints/tags/update-tag.dto'; +import { CreateWatchlistItemDto } from '@ghostfolio/api/app/endpoints/watchlist/create-watchlist-item.dto'; import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto'; import { Activities, @@ -44,7 +45,8 @@ import { PortfolioPerformanceResponse, PortfolioReportResponse, PublicPortfolioResponse, - User + User, + WatchlistResponse } from '@ghostfolio/common/interfaces'; import { filterGlobalPermissions } from '@ghostfolio/common/permissions'; import type { @@ -686,6 +688,10 @@ export class DataService { return this.http.get('/api/v1/tags'); } + public fetchWatchlist() { + return this.http.get('/api/v1/watchlist'); + } + public generateAccessToken(aUserId: string) { return this.http.post( `/api/v1/user/${aUserId}/access-token`, @@ -748,6 +754,10 @@ export class DataService { return this.http.post('/api/v1/user', {}); } + public postWatchlistItem(watchlistItem: CreateWatchlistItemDto) { + return this.http.post('/api/v1/watchlist', watchlistItem); + } + public putAccount(aAccount: UpdateAccountDto) { return this.http.put(`/api/v1/account/${aAccount.id}`, aAccount); } diff --git a/libs/common/src/lib/interfaces/index.ts b/libs/common/src/lib/interfaces/index.ts index c93ab2d27..b83b6d5f8 100644 --- a/libs/common/src/lib/interfaces/index.ts +++ b/libs/common/src/lib/interfaces/index.ts @@ -57,6 +57,7 @@ import type { PortfolioPerformanceResponse } from './responses/portfolio-perform import type { PortfolioReportResponse } from './responses/portfolio-report.interface'; import type { PublicPortfolioResponse } from './responses/public-portfolio-response.interface'; import type { QuotesResponse } from './responses/quotes-response.interface'; +import type { WatchlistResponse } from './responses/watchlist-response.interface'; import type { ScraperConfiguration } from './scraper-configuration.interface'; import type { Statistics } from './statistics.interface'; import type { SubscriptionOffer } from './subscription-offer.interface'; @@ -135,5 +136,6 @@ export { ToggleOption, User, UserSettings, + WatchlistResponse, XRayRulesSettings }; diff --git a/libs/common/src/lib/interfaces/responses/watchlist-response.interface.ts b/libs/common/src/lib/interfaces/responses/watchlist-response.interface.ts new file mode 100644 index 000000000..3cdc834b4 --- /dev/null +++ b/libs/common/src/lib/interfaces/responses/watchlist-response.interface.ts @@ -0,0 +1,5 @@ +import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces'; + +export interface WatchlistResponse { + watchlist: AssetProfileIdentifier[]; +} diff --git a/libs/ui/src/lib/benchmark/benchmark.component.html b/libs/ui/src/lib/benchmark/benchmark.component.html index 8867e1c9e..8e8a30202 100644 --- a/libs/ui/src/lib/benchmark/benchmark.component.html +++ b/libs/ui/src/lib/benchmark/benchmark.component.html @@ -66,11 +66,13 @@
- + @if (element?.performances?.allTimeHigh?.date) { + + }
@@ -83,18 +85,20 @@ from ATH - + @if (isNumber(element?.performances?.allTimeHigh?.performancePercent)) { + + } diff --git a/libs/ui/src/lib/benchmark/benchmark.component.ts b/libs/ui/src/lib/benchmark/benchmark.component.ts index cc5815a0c..0c5e8854a 100644 --- a/libs/ui/src/lib/benchmark/benchmark.component.ts +++ b/libs/ui/src/lib/benchmark/benchmark.component.ts @@ -20,6 +20,7 @@ import { import { MatDialog } from '@angular/material/dialog'; import { MatTableModule } from '@angular/material/table'; import { ActivatedRoute, Router, RouterModule } from '@angular/router'; +import { isNumber } from 'lodash'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { Subject, takeUntil } from 'rxjs'; @@ -49,6 +50,7 @@ export class GfBenchmarkComponent implements OnChanges, OnDestroy { public displayedColumns = ['name', 'date', 'change', 'marketCondition']; public isLoading = true; + public isNumber = isNumber; public resolveMarketCondition = resolveMarketCondition; public translate = translate;